Initial commit
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* 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*(.*)$/
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface ContextUsageLike {
|
||||
contextTokens?: number | null
|
||||
contextWindow?: number | null
|
||||
contextRemainingPct?: number | null
|
||||
}
|
||||
|
||||
export interface ContextUsageMetrics {
|
||||
usedPct?: number
|
||||
remainingPct?: number
|
||||
usedTokens?: number
|
||||
windowTokens?: number
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: number | null | undefined): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
function clampPct(value: number): number {
|
||||
return Math.max(0, Math.min(Math.round(value), 100))
|
||||
}
|
||||
|
||||
export function getContextUsageMetrics(
|
||||
value: ContextUsageLike | null | undefined,
|
||||
): ContextUsageMetrics {
|
||||
const contextTokens = asFiniteNumber(value?.contextTokens)
|
||||
const contextWindow = asFiniteNumber(value?.contextWindow)
|
||||
const contextRemainingPct = asFiniteNumber(value?.contextRemainingPct)
|
||||
|
||||
const normalizedTokens = typeof contextTokens === 'number' ? Math.max(0, Math.round(contextTokens)) : undefined
|
||||
const normalizedWindow = typeof contextWindow === 'number' && contextWindow > 0
|
||||
? Math.max(1, Math.round(contextWindow))
|
||||
: undefined
|
||||
const normalizedRemainingPct = typeof contextRemainingPct === 'number'
|
||||
? clampPct(contextRemainingPct)
|
||||
: undefined
|
||||
|
||||
if (typeof normalizedTokens === 'number' && typeof normalizedWindow === 'number') {
|
||||
const usedTokens = Math.min(normalizedTokens, normalizedWindow)
|
||||
const usedPct = clampPct((usedTokens / normalizedWindow) * 100)
|
||||
return {
|
||||
usedPct,
|
||||
remainingPct: 100 - usedPct,
|
||||
usedTokens,
|
||||
windowTokens: normalizedWindow,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof normalizedRemainingPct === 'number' && typeof normalizedWindow === 'number') {
|
||||
const usedPct = 100 - normalizedRemainingPct
|
||||
return {
|
||||
usedPct,
|
||||
remainingPct: normalizedRemainingPct,
|
||||
usedTokens: Math.round((usedPct / 100) * normalizedWindow),
|
||||
windowTokens: normalizedWindow,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof normalizedWindow === 'number') {
|
||||
return {
|
||||
usedPct: 0,
|
||||
remainingPct: 100,
|
||||
usedTokens: 0,
|
||||
windowTokens: normalizedWindow,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof normalizedRemainingPct === 'number') {
|
||||
// No usable window: the used/max ratio is undefined. Upstream reports an
|
||||
// unknown window as remaining_pct=0, so deriving usedPct here would render
|
||||
// a misleading 100% (or 0% at turn start). Surface remaining for any text
|
||||
// display but do not drive the ring — the ring hides without a usedPct.
|
||||
return { remainingPct: normalizedRemainingPct }
|
||||
}
|
||||
|
||||
if (typeof normalizedTokens === 'number') {
|
||||
return { usedTokens: normalizedTokens }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Locks in: frontend's PHASE_TO_COLUMN matches backend's
|
||||
* ``opc/presentation/kanban.py:STATUS_TO_COLUMN`` and
|
||||
* ``opc/layer2_organization/phase.py:_PHASE_TO_COLUMN``.
|
||||
*
|
||||
* If the backend ever adds, removes, or renames a phase / column, this
|
||||
* test surfaces the drift immediately — preventing a class of silent
|
||||
* UI bug where a card ends up in the wrong column because the frontend
|
||||
* projection fell out of sync with the backend.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import type { KanbanPhase } from '../types/kanban'
|
||||
import { PHASE_TO_COLUMN, deriveColumnFromPhase } from './phaseHelpers'
|
||||
|
||||
const ALL_PHASES: 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',
|
||||
]
|
||||
|
||||
describe('PHASE_TO_COLUMN', () => {
|
||||
it('covers every KanbanPhase exactly once', () => {
|
||||
expect(Object.keys(PHASE_TO_COLUMN).sort()).toEqual([...ALL_PHASES].sort())
|
||||
})
|
||||
|
||||
it('projects TODO-family phases to "todo"', () => {
|
||||
for (const p of ['queued', 'ready', 'ready_for_rework', 'waiting_dependencies'] as KanbanPhase[]) {
|
||||
expect(PHASE_TO_COLUMN[p]).toBe('todo')
|
||||
}
|
||||
})
|
||||
|
||||
it('projects IN-PROGRESS-family phases to "in-progress"', () => {
|
||||
for (const p of ['running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention'] as KanbanPhase[]) {
|
||||
expect(PHASE_TO_COLUMN[p]).toBe('in-progress')
|
||||
}
|
||||
})
|
||||
|
||||
it('projects IN-REVIEW-family phases to "in-review"', () => {
|
||||
for (const p of ['awaiting_manager_review', 'awaiting_human'] as KanbanPhase[]) {
|
||||
expect(PHASE_TO_COLUMN[p]).toBe('in-review')
|
||||
}
|
||||
})
|
||||
|
||||
it('projects terminal phases to "done"', () => {
|
||||
for (const p of ['approved', 'failed', 'cancelled'] as KanbanPhase[]) {
|
||||
expect(PHASE_TO_COLUMN[p]).toBe('done')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveColumnFromPhase', () => {
|
||||
it('returns "todo" for undefined / null phase', () => {
|
||||
expect(deriveColumnFromPhase(undefined)).toBe('todo')
|
||||
expect(deriveColumnFromPhase(null)).toBe('todo')
|
||||
})
|
||||
|
||||
it('projects each phase using the same table', () => {
|
||||
for (const p of ALL_PHASES) {
|
||||
expect(deriveColumnFromPhase(p)).toBe(PHASE_TO_COLUMN[p])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Single-source-of-truth projection: KanbanPhase → kanban column id.
|
||||
*
|
||||
* This mirror MUST stay in sync with the backend projection in
|
||||
* ``opc/presentation/kanban.py:STATUS_TO_COLUMN`` and
|
||||
* ``opc/layer2_organization/phase.py:_PHASE_TO_COLUMN``. The test in
|
||||
* ``phaseHelpers.test.ts`` locks the mapping by enumerating all 14
|
||||
* phases; if the backend ever renames or reshapes the column set, that
|
||||
* test catches the drift before the UI silently mis-groups cards.
|
||||
*
|
||||
* The UI previously grouped cards by the backend-supplied ``columnId``
|
||||
* field, which is itself a projection of phase on the backend. Moving
|
||||
* the projection into the frontend removes a layer of indirection and
|
||||
* lets the UI stay internally consistent when future optimistic writes
|
||||
* only know the phase intent, not the derived column.
|
||||
*/
|
||||
|
||||
import type { KanbanPhase } from '../types/kanban'
|
||||
|
||||
export const PHASE_TO_COLUMN: Record<KanbanPhase, string> = {
|
||||
// todo
|
||||
queued: 'todo',
|
||||
ready: 'todo',
|
||||
ready_for_rework: 'todo',
|
||||
waiting_dependencies: 'todo',
|
||||
// in-progress
|
||||
running: 'in-progress',
|
||||
waiting_for_peer: 'in-progress',
|
||||
waiting_for_children: 'in-progress',
|
||||
paused: 'in-progress',
|
||||
needs_attention: 'in-progress',
|
||||
// in-review
|
||||
awaiting_manager_review: 'in-review',
|
||||
awaiting_human: 'in-review',
|
||||
// done
|
||||
approved: 'done',
|
||||
failed: 'done',
|
||||
cancelled: 'done',
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the kanban column for a task based on its phase.
|
||||
* Falls back to ``'todo'`` when phase is missing or unknown; callers
|
||||
* that already have a backend-supplied ``columnId`` should prefer that
|
||||
* value during the transition window and only use this helper when the
|
||||
* phase is trustworthy.
|
||||
*/
|
||||
export function deriveColumnFromPhase(phase: KanbanPhase | undefined | null): string {
|
||||
if (!phase) return 'todo'
|
||||
const mapped = PHASE_TO_COLUMN[phase]
|
||||
return mapped ?? 'todo'
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ProgressEntry } from '../types/kanban'
|
||||
|
||||
function compact(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 96)
|
||||
}
|
||||
|
||||
export function progressEntryKey(entry: ProgressEntry, fallbackIndex = 0): string {
|
||||
const stableId = entry.itemId || entry.streamId || entry.toolCallId || entry.permissionGroupKey
|
||||
if (stableId) {
|
||||
return `${entry.type}:${compact(entry.turnId)}:${compact(stableId)}`
|
||||
}
|
||||
|
||||
if (entry.type === 'thinking') {
|
||||
return `thinking:${compact(entry.turnId) || compact(entry.executionMode) || compact(entry.summary) || 'stream'}:${fallbackIndex}`
|
||||
}
|
||||
|
||||
if (entry.type === 'tool_call' && entry.turnId) {
|
||||
return `tool:${compact(entry.turnId)}:${compact(entry.summary) || 'tool'}:${fallbackIndex}`
|
||||
}
|
||||
|
||||
if (entry.turnId && typeof entry.seq === 'number') {
|
||||
return `${entry.type}:${compact(entry.turnId)}:seq:${entry.seq}`
|
||||
}
|
||||
|
||||
return [
|
||||
entry.type,
|
||||
compact(entry.turnId),
|
||||
Number.isFinite(entry.timestamp) ? entry.timestamp : '',
|
||||
compact(entry.summary),
|
||||
compact(entry.detail),
|
||||
fallbackIndex,
|
||||
].join(':')
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { appendProgressEntry } from './progressLog'
|
||||
|
||||
let log = appendProgressEntry([], {
|
||||
timestamp: 1,
|
||||
type: 'thinking',
|
||||
summary: 'Thinking',
|
||||
detail: '我先',
|
||||
turnId: 'rt-1:1',
|
||||
itemId: 'rt-1:1:thinking',
|
||||
seq: 1,
|
||||
})
|
||||
|
||||
for (const [seq, detail] of [
|
||||
[2, '先联网'],
|
||||
[3, '联网抓'],
|
||||
[4, '抓取'],
|
||||
] as const) {
|
||||
log = appendProgressEntry(log, {
|
||||
timestamp: seq,
|
||||
type: 'thinking',
|
||||
summary: 'Thinking',
|
||||
detail,
|
||||
turnId: 'rt-1:1',
|
||||
itemId: 'rt-1:1:thinking',
|
||||
seq,
|
||||
})
|
||||
}
|
||||
|
||||
assert.equal(log.length, 1)
|
||||
assert.equal(log[0]?.summary, 'Thinking')
|
||||
assert.equal(log[0]?.detail, '我先联网抓取')
|
||||
|
||||
const unchanged = appendProgressEntry(log, {
|
||||
timestamp: 5,
|
||||
type: 'thinking',
|
||||
summary: 'Thinking',
|
||||
detail: '重复',
|
||||
turnId: 'rt-1:1',
|
||||
itemId: 'rt-1:1:thinking',
|
||||
seq: 4,
|
||||
})
|
||||
|
||||
assert.equal(unchanged[0]?.detail, '我先联网抓取')
|
||||
|
||||
let toolLog = appendProgressEntry([], {
|
||||
timestamp: 10,
|
||||
type: 'tool_call',
|
||||
summary: 'web_search',
|
||||
detail: '{"query":"weather"}',
|
||||
turnId: 'rt-1:2',
|
||||
toolCallId: 'call-1',
|
||||
})
|
||||
|
||||
toolLog = appendProgressEntry(toolLog, {
|
||||
timestamp: 11,
|
||||
type: 'tool_call',
|
||||
summary: 'web_search',
|
||||
detail: 'completed',
|
||||
turnId: 'rt-1:2',
|
||||
toolCallId: 'call-1',
|
||||
})
|
||||
|
||||
assert.equal(toolLog.length, 1)
|
||||
assert.equal(toolLog[0]?.detail, '{"query":"weather"}\ncompleted')
|
||||
|
||||
let permissionLog = appendProgressEntry([], {
|
||||
timestamp: 20,
|
||||
type: 'autonomy',
|
||||
summary: 'shell_exec: ask',
|
||||
turnId: 'rt-1:3',
|
||||
permissionGroupKey: 'tool:shell_exec/python:domain:example.com',
|
||||
})
|
||||
|
||||
permissionLog = appendProgressEntry(permissionLog, {
|
||||
timestamp: 21,
|
||||
type: 'autonomy',
|
||||
summary: 'shell_exec: allow',
|
||||
turnId: 'rt-1:3',
|
||||
permissionGroupKey: 'tool:shell_exec/python:domain:example.com',
|
||||
})
|
||||
|
||||
assert.equal(permissionLog.length, 1)
|
||||
assert.equal(permissionLog[0]?.summary, 'shell_exec: allow')
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { ProgressEntry, WorkItemProgressEntry } from '../types/kanban'
|
||||
|
||||
const STREAM_MERGE_WINDOW_MS = 4000
|
||||
|
||||
function clampEntries<T>(entries: T[], maxEntries: number): T[] {
|
||||
return entries.length > maxEntries ? entries.slice(-maxEntries) : entries
|
||||
}
|
||||
|
||||
function mergeText(left: string, right: string, kind: 'thinking' | 'tool_call'): string {
|
||||
if (!left) return right
|
||||
if (!right) return left
|
||||
if (left === right) return left
|
||||
if (right.startsWith(left)) return right
|
||||
if (left.startsWith(right)) return left
|
||||
if (left.endsWith(right)) return left
|
||||
if (right.endsWith(left)) return right
|
||||
const maxOverlap = Math.min(left.length, right.length)
|
||||
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
||||
if (left.slice(-overlap) === right.slice(0, overlap)) {
|
||||
return `${left}${right.slice(overlap)}`
|
||||
}
|
||||
}
|
||||
if (kind === 'tool_call' && /[}\]"]$/.test(left) && !/^\s/.test(right)) {
|
||||
return `${left}\n${right}`
|
||||
}
|
||||
return `${left}${right}`
|
||||
}
|
||||
|
||||
function summarizeThinking(detail: string, fallback: string): string {
|
||||
void detail
|
||||
void fallback
|
||||
return 'Thinking'
|
||||
}
|
||||
|
||||
function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry {
|
||||
return {
|
||||
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
|
||||
type: entry.type,
|
||||
summary: typeof entry.summary === 'string' ? entry.summary : '',
|
||||
detail: typeof entry.detail === 'string' && entry.detail ? entry.detail : undefined,
|
||||
turnId: typeof entry.turnId === 'string' && entry.turnId ? entry.turnId : undefined,
|
||||
itemId: typeof entry.itemId === 'string' && entry.itemId ? entry.itemId : undefined,
|
||||
streamId: typeof entry.streamId === 'string' && entry.streamId ? entry.streamId : undefined,
|
||||
toolCallId: typeof entry.toolCallId === 'string' && entry.toolCallId ? entry.toolCallId : undefined,
|
||||
permissionGroupKey: typeof entry.permissionGroupKey === 'string' && entry.permissionGroupKey ? entry.permissionGroupKey : undefined,
|
||||
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
|
||||
executionMode: typeof entry.executionMode === 'string' && entry.executionMode ? entry.executionMode : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function streamKey(entry: ProgressEntry): string {
|
||||
const itemKey = entry.itemId || entry.streamId
|
||||
if (!itemKey) {
|
||||
if (entry.toolCallId && (entry.type === 'tool_call' || entry.type === 'autonomy')) {
|
||||
return `${entry.type}:${entry.turnId ?? ''}:${entry.toolCallId}`
|
||||
}
|
||||
if (entry.permissionGroupKey && entry.type === 'autonomy') {
|
||||
return `${entry.type}:${entry.turnId ?? ''}:${entry.permissionGroupKey}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return `${entry.type}:${entry.turnId ?? ''}:${itemKey}`
|
||||
}
|
||||
|
||||
function canMergeProgress(left: ProgressEntry, right: ProgressEntry): boolean {
|
||||
const leftKey = streamKey(left)
|
||||
const rightKey = streamKey(right)
|
||||
if (leftKey && rightKey) return leftKey === rightKey
|
||||
if (right.timestamp - left.timestamp > STREAM_MERGE_WINDOW_MS) return false
|
||||
if (left.type !== right.type) return false
|
||||
if (left.type === 'thinking') return true
|
||||
if (left.type === 'tool_call') return left.summary === right.summary
|
||||
return false
|
||||
}
|
||||
|
||||
function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean {
|
||||
return (
|
||||
right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
|
||||
&& left.type === right.type
|
||||
&& left.summary === right.summary
|
||||
&& (left.detail ?? '') === (right.detail ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry {
|
||||
if (left.type === 'thinking') {
|
||||
const detail = mergeText(left.detail ?? left.summary, right.detail ?? right.summary, 'thinking')
|
||||
return {
|
||||
timestamp: right.timestamp,
|
||||
type: 'thinking',
|
||||
summary: summarizeThinking(detail, right.summary || left.summary),
|
||||
detail: detail || undefined,
|
||||
turnId: right.turnId ?? left.turnId,
|
||||
itemId: right.itemId ?? left.itemId,
|
||||
streamId: right.streamId ?? left.streamId,
|
||||
toolCallId: right.toolCallId ?? left.toolCallId,
|
||||
permissionGroupKey: right.permissionGroupKey ?? left.permissionGroupKey,
|
||||
seq: right.seq ?? left.seq,
|
||||
executionMode: right.executionMode ?? left.executionMode,
|
||||
}
|
||||
}
|
||||
|
||||
if (left.type === 'tool_call') {
|
||||
const mergedDetail = mergeText(left.detail ?? '', right.detail ?? '', 'tool_call')
|
||||
return {
|
||||
timestamp: right.timestamp,
|
||||
type: 'tool_call',
|
||||
summary: right.summary || left.summary,
|
||||
detail: mergedDetail || undefined,
|
||||
turnId: right.turnId ?? left.turnId,
|
||||
itemId: right.itemId ?? left.itemId,
|
||||
streamId: right.streamId ?? left.streamId,
|
||||
toolCallId: right.toolCallId ?? left.toolCallId,
|
||||
permissionGroupKey: right.permissionGroupKey ?? left.permissionGroupKey,
|
||||
seq: right.seq ?? left.seq,
|
||||
executionMode: right.executionMode ?? left.executionMode,
|
||||
}
|
||||
}
|
||||
|
||||
return right
|
||||
}
|
||||
|
||||
export function appendProgressEntry(
|
||||
log: ProgressEntry[],
|
||||
entry: ProgressEntry,
|
||||
maxEntries = 100,
|
||||
): ProgressEntry[] {
|
||||
const normalized = normalizeProgressEntry(entry)
|
||||
const normalizedKey = streamKey(normalized)
|
||||
const targetIndex = normalizedKey
|
||||
? [...log].reverse().findIndex(existing => streamKey(existing) === normalizedKey)
|
||||
: -1
|
||||
const actualIndex = targetIndex >= 0 ? log.length - 1 - targetIndex : log.length - 1
|
||||
const last = log[actualIndex]
|
||||
if (!last) return [normalized]
|
||||
if (
|
||||
normalizedKey
|
||||
&& typeof last.seq === 'number'
|
||||
&& typeof normalized.seq === 'number'
|
||||
&& normalized.seq <= last.seq
|
||||
) {
|
||||
return log
|
||||
}
|
||||
if (isDuplicateProgress(last, normalized)) {
|
||||
return clampEntries([
|
||||
...log.slice(0, actualIndex),
|
||||
{ ...last, timestamp: normalized.timestamp },
|
||||
...log.slice(actualIndex + 1),
|
||||
], maxEntries)
|
||||
}
|
||||
if (canMergeProgress(last, normalized)) {
|
||||
return clampEntries([
|
||||
...log.slice(0, actualIndex),
|
||||
mergeProgress(last, normalized),
|
||||
...log.slice(actualIndex + 1),
|
||||
], maxEntries)
|
||||
}
|
||||
return clampEntries([...log, normalized], maxEntries)
|
||||
}
|
||||
|
||||
export function normalizeProgressLog(log: ProgressEntry[], maxEntries = 100): ProgressEntry[] {
|
||||
return (Array.isArray(log) ? log : []).reduce<ProgressEntry[]>(
|
||||
(acc, entry) => appendProgressEntry(acc, entry, maxEntries),
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeWorkItemEntry(entry: WorkItemProgressEntry): WorkItemProgressEntry {
|
||||
return {
|
||||
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
|
||||
type: entry.type,
|
||||
workItemProjectionId: typeof entry.workItemProjectionId === 'string' && entry.workItemProjectionId ? entry.workItemProjectionId : undefined,
|
||||
workItemTurnType: typeof entry.workItemTurnType === 'string' && entry.workItemTurnType ? entry.workItemTurnType : undefined,
|
||||
workItemProjectionTitle: typeof entry.workItemProjectionTitle === 'string' && entry.workItemProjectionTitle ? entry.workItemProjectionTitle : undefined,
|
||||
runtimeTaskId: typeof entry.runtimeTaskId === 'string' && entry.runtimeTaskId ? entry.runtimeTaskId : undefined,
|
||||
executionTurnId: typeof entry.executionTurnId === 'string' && entry.executionTurnId ? entry.executionTurnId : undefined,
|
||||
roleName: typeof entry.roleName === 'string' && entry.roleName ? entry.roleName : undefined,
|
||||
detail: typeof entry.detail === 'string' && entry.detail ? entry.detail : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function sameWorkItemScope(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
|
||||
return (
|
||||
left.type === right.type
|
||||
&& (left.workItemProjectionId ?? '') === (right.workItemProjectionId ?? '')
|
||||
&& (left.workItemTurnType ?? '') === (right.workItemTurnType ?? '')
|
||||
&& (left.workItemProjectionTitle ?? '') === (right.workItemProjectionTitle ?? '')
|
||||
&& (left.executionTurnId ?? left.runtimeTaskId ?? '') === (right.executionTurnId ?? right.runtimeTaskId ?? '')
|
||||
&& (left.roleName ?? '') === (right.roleName ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function canMergeWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
|
||||
return (
|
||||
right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
|
||||
&& sameWorkItemScope(left, right)
|
||||
&& (left.type === 'thinking' || left.type === 'tool_call')
|
||||
)
|
||||
}
|
||||
|
||||
function isDuplicateWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
|
||||
return sameWorkItemScope(left, right) && (left.detail ?? '') === (right.detail ?? '') && right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
|
||||
}
|
||||
|
||||
function mergeWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): WorkItemProgressEntry {
|
||||
const kind = right.type === 'tool_call' ? 'tool_call' : 'thinking'
|
||||
return {
|
||||
...left,
|
||||
...right,
|
||||
timestamp: right.timestamp,
|
||||
detail: mergeText(left.detail ?? '', right.detail ?? '', kind) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function appendWorkItemProgressEntry(
|
||||
log: WorkItemProgressEntry[],
|
||||
entry: WorkItemProgressEntry,
|
||||
maxEntries = 100,
|
||||
): WorkItemProgressEntry[] {
|
||||
const normalized = normalizeWorkItemEntry(entry)
|
||||
const last = log[log.length - 1]
|
||||
if (!last) return [normalized]
|
||||
if (isDuplicateWorkItem(last, normalized)) {
|
||||
return clampEntries([...log.slice(0, -1), { ...last, timestamp: normalized.timestamp }], maxEntries)
|
||||
}
|
||||
if (canMergeWorkItem(last, normalized)) {
|
||||
return clampEntries([...log.slice(0, -1), mergeWorkItem(last, normalized)], maxEntries)
|
||||
}
|
||||
return clampEntries([...log, normalized], maxEntries)
|
||||
}
|
||||
|
||||
export function normalizeWorkItemLog(log: WorkItemProgressEntry[], maxEntries = 100): WorkItemProgressEntry[] {
|
||||
return (Array.isArray(log) ? log : []).reduce<WorkItemProgressEntry[]>(
|
||||
(acc, entry) => appendWorkItemProgressEntry(acc, entry, maxEntries),
|
||||
[],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mapBackendSession } from './collabSync'
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* roleWorkItems deserialization tests
|
||||
*
|
||||
* Locks the snake_case → camelCase conversion at the collabSync boundary
|
||||
* so a backend rename or a missing field surfaces here, not as a silent
|
||||
* UI regression in WorkItemProgressCard. The schema this matches is
|
||||
* produced by ``snapshot_builder._build_role_work_items_for_session`` —
|
||||
* keep these two in sync.
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const session = mapBackendSession({
|
||||
task_id: 'task-1',
|
||||
channel_id: 'session:task-1',
|
||||
status: 'running',
|
||||
exec_mode: 'company',
|
||||
is_company_runtime: true,
|
||||
role_work_items: {
|
||||
engineer: {
|
||||
role_key: 'engineer',
|
||||
role_id: 'engineer',
|
||||
role_name: 'Engineer',
|
||||
runtime_status: 'tool_active',
|
||||
aggregated_status: 'active',
|
||||
work_items: [
|
||||
{
|
||||
work_item_id: 'wi-1',
|
||||
work_item_projection_id: 'proj-engineer-1',
|
||||
phase: 'running',
|
||||
kanban_column: 'in-progress',
|
||||
title: 'Implement summary',
|
||||
kind: 'execute',
|
||||
is_review_target: false,
|
||||
executor_role_id: 'engineer',
|
||||
executor_role_name: 'Engineer',
|
||||
reviewer_role_id: 'cto',
|
||||
created_at: 100.0,
|
||||
updated_at: 200.0,
|
||||
execution_turn_id: 'runtime-task-1',
|
||||
activity_sections: [
|
||||
{
|
||||
kind: 'execute',
|
||||
title: 'Execute',
|
||||
role_name: 'Engineer',
|
||||
runtime_task_id: 'runtime-task-1',
|
||||
entries: [
|
||||
{ timestamp: 151.0, type: 'thinking', summary: 'plan' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'report',
|
||||
title: 'Report',
|
||||
role_name: 'Engineer',
|
||||
runtime_task_id: 'runtime-report-1',
|
||||
entries: [
|
||||
{ timestamp: 175.0, type: 'tool_call', summary: 'write_report' },
|
||||
],
|
||||
},
|
||||
],
|
||||
progress_log: [
|
||||
{ timestamp: 150.0, type: 'tool_call', summary: 'edit_file' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
cto: {
|
||||
role_key: 'cto',
|
||||
role_id: 'cto',
|
||||
role_name: 'CTO',
|
||||
runtime_status: 'idle',
|
||||
aggregated_status: 'waiting',
|
||||
work_items: [
|
||||
{
|
||||
work_item_id: 'wi-2',
|
||||
work_item_projection_id: 'proj-engineer-1',
|
||||
phase: 'awaiting_manager_review',
|
||||
kanban_column: 'in-review',
|
||||
title: 'Implement summary',
|
||||
kind: 'execute',
|
||||
is_review_target: true,
|
||||
executor_role_id: 'engineer',
|
||||
reviewer_role_id: 'cto',
|
||||
created_at: 50.0,
|
||||
updated_at: 250.0,
|
||||
execution_turn_id: 'runtime-task-1',
|
||||
progress_log: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
executor_role_work_items: {
|
||||
engineer: {
|
||||
role_key: 'engineer',
|
||||
role_id: 'engineer',
|
||||
role_name: 'Engineer',
|
||||
runtime_status: 'idle',
|
||||
aggregated_status: 'waiting',
|
||||
work_items: [
|
||||
{
|
||||
work_item_id: 'wi-2',
|
||||
work_item_projection_id: 'proj-engineer-1',
|
||||
phase: 'awaiting_manager_review',
|
||||
kanban_column: 'in-review',
|
||||
title: 'Implement summary',
|
||||
kind: 'execute',
|
||||
is_review_target: true,
|
||||
executor_role_id: 'engineer',
|
||||
executor_role_name: 'Engineer',
|
||||
reviewer_role_id: 'cto',
|
||||
created_at: 50.0,
|
||||
updated_at: 250.0,
|
||||
execution_turn_id: 'runtime-task-1',
|
||||
progress_log: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const roleWorkItems = session.roleWorkItems
|
||||
assert.ok(roleWorkItems, 'roleWorkItems should be parsed')
|
||||
assert.deepEqual(Object.keys(roleWorkItems!).sort(), ['cto', 'engineer'])
|
||||
|
||||
const engineer = roleWorkItems!.engineer
|
||||
assert.equal(engineer.aggregatedStatus, 'active')
|
||||
assert.equal(engineer.runtimeStatus, 'tool_active')
|
||||
assert.equal(engineer.workItems.length, 1)
|
||||
assert.equal(engineer.workItems[0].phase, 'running')
|
||||
assert.equal(engineer.workItems[0].kanbanColumn, 'in-progress')
|
||||
assert.equal(engineer.workItems[0].executionTurnId, 'runtime-task-1')
|
||||
assert.equal(engineer.workItems[0].progressLog.length, 1)
|
||||
assert.equal(engineer.workItems[0].progressLog[0].summary, 'edit_file')
|
||||
assert.equal(engineer.workItems[0].activitySections?.length, 2)
|
||||
assert.equal(engineer.workItems[0].activitySections?.[0].roleName, 'Engineer')
|
||||
assert.equal(engineer.workItems[0].activitySections?.[1].runtimeTaskId, 'runtime-report-1')
|
||||
assert.equal(engineer.workItems[0].activitySections?.[1].entries[0].summary, 'write_report')
|
||||
|
||||
const cto = roleWorkItems!.cto
|
||||
assert.equal(cto.aggregatedStatus, 'waiting')
|
||||
assert.equal(cto.runtimeStatus, 'idle')
|
||||
assert.equal(cto.workItems.length, 1)
|
||||
assert.equal(cto.workItems[0].isReviewTarget, true)
|
||||
assert.equal(cto.workItems[0].kanbanColumn, 'in-review')
|
||||
|
||||
const executorRoleWorkItems = session.executorRoleWorkItems
|
||||
assert.ok(executorRoleWorkItems, 'executorRoleWorkItems should be parsed')
|
||||
assert.deepEqual(Object.keys(executorRoleWorkItems!).sort(), ['engineer'])
|
||||
assert.equal(executorRoleWorkItems!.engineer.workItems.length, 1)
|
||||
assert.equal(executorRoleWorkItems!.engineer.workItems[0].isReviewTarget, true)
|
||||
assert.equal(executorRoleWorkItems!.engineer.workItems[0].reviewerRoleId, 'cto')
|
||||
assert.equal(executorRoleWorkItems!.engineer.aggregatedStatus, 'waiting')
|
||||
|
||||
// Sanity: an unknown phase falls back to ``queued`` (safest "not started"
|
||||
// label) rather than throwing — a backend with a temporarily stale enum
|
||||
// shouldn't crash the panel.
|
||||
const sessionWithBadPhase = mapBackendSession({
|
||||
task_id: 'task-2',
|
||||
channel_id: 'session:task-2',
|
||||
status: 'running',
|
||||
role_work_items: {
|
||||
engineer: {
|
||||
role_key: 'engineer',
|
||||
role_id: 'engineer',
|
||||
role_name: 'Engineer',
|
||||
runtime_status: 'idle',
|
||||
aggregated_status: 'pending',
|
||||
work_items: [
|
||||
{
|
||||
work_item_id: 'wi-bad',
|
||||
work_item_projection_id: 'proj-x',
|
||||
phase: 'totally_made_up',
|
||||
kanban_column: 'todo',
|
||||
title: 'Mystery item',
|
||||
created_at: 10.0,
|
||||
updated_at: 10.0,
|
||||
progress_log: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const fallbackRow = sessionWithBadPhase.roleWorkItems!.engineer.workItems[0]
|
||||
assert.equal(fallbackRow.phase, 'queued')
|
||||
|
||||
// Empty / missing payload normalizes to ``undefined`` so the consumer can
|
||||
// branch on ``.roleWorkItems != null`` without falsy-checking against {}.
|
||||
const sessionWithoutPayload = mapBackendSession({
|
||||
task_id: 'task-3',
|
||||
channel_id: 'session:task-3',
|
||||
status: 'running',
|
||||
})
|
||||
assert.equal(sessionWithoutPayload.roleWorkItems, undefined)
|
||||
assert.equal(sessionWithoutPayload.executorRoleWorkItems, undefined)
|
||||
|
||||
const sessionWithEmptyPayload = mapBackendSession({
|
||||
task_id: 'task-4',
|
||||
channel_id: 'session:task-4',
|
||||
status: 'running',
|
||||
role_work_items: {},
|
||||
executor_role_work_items: {},
|
||||
})
|
||||
assert.equal(sessionWithEmptyPayload.roleWorkItems, undefined)
|
||||
assert.equal(sessionWithEmptyPayload.executorRoleWorkItems, undefined)
|
||||
|
||||
console.log('roleWorkItems deserialization checks passed')
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { getRuntimeOrgView, normalizeOrgInfoPayload } from './runtimeOrg'
|
||||
|
||||
const payload = normalizeOrgInfoPayload({
|
||||
roles: [],
|
||||
employees: [],
|
||||
channels: [],
|
||||
connectors: [],
|
||||
company_profile: 'corporate',
|
||||
organization_id: 'quantum_harbor',
|
||||
organization_name: 'Quantum Harbor Research Studio',
|
||||
organization_config_file: 'company_orgs/org_quantum_harbor_config.yaml',
|
||||
org_version: 2,
|
||||
runtime_topology_version: 2,
|
||||
runtime_teams: [
|
||||
{
|
||||
cell_id: 'cell-2',
|
||||
manager_role_id: 'mgr-2',
|
||||
member_role_ids: ['role-b'],
|
||||
status: 'idle',
|
||||
},
|
||||
],
|
||||
runtime_seats: [
|
||||
{
|
||||
role_session_id: 'seat-2',
|
||||
role_id: 'role-b',
|
||||
employee_id: 'emp-2',
|
||||
status: 'cold',
|
||||
},
|
||||
],
|
||||
work_items: [
|
||||
{
|
||||
work_item_id: 'wi-2',
|
||||
role_id: 'role-b',
|
||||
cell_id: 'cell-2',
|
||||
title: 'Runtime work',
|
||||
kind: 'execute',
|
||||
phase: 'ready',
|
||||
metadata: {
|
||||
adaptive: {
|
||||
normalized_state: 'waiting_for_gate',
|
||||
blocked_reason: 'Waiting for required signals: implementation_ready',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
frontier: {
|
||||
status: 'paused',
|
||||
total_work_items: 1,
|
||||
},
|
||||
project_run: {
|
||||
run_id: 'run-modern',
|
||||
lifecycle_status: 'deliverable',
|
||||
current_revision: 2,
|
||||
},
|
||||
project_dossier: {
|
||||
latest_deliverable_summary: 'Ship candidate ready',
|
||||
open_issues: ['Need QA sign-off'],
|
||||
},
|
||||
seat_digests: [
|
||||
{
|
||||
seat_id: 'seat-2',
|
||||
team_id: 'cell-2',
|
||||
manager_digest: {
|
||||
pending_decisions: [{ subject: 'Approve release' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
revision_links: [
|
||||
{
|
||||
link_id: 'link-1',
|
||||
session_id: 'session-new',
|
||||
linked_session_id: 'session-old',
|
||||
link_type: 'revision_of',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.equal(payload.runtime_teams?.[0]?.cell_id, 'cell-2')
|
||||
assert.equal(payload.runtime_seats?.[0]?.role_session_id, 'seat-2')
|
||||
assert.equal(payload.work_items?.[0]?.work_item_id, 'wi-2')
|
||||
assert.equal(payload.frontier?.status, 'paused')
|
||||
assert.equal(payload.organization_id, 'quantum_harbor')
|
||||
assert.equal(payload.organization_name, 'Quantum Harbor Research Studio')
|
||||
assert.equal(payload.organization_config_file, 'company_orgs/org_quantum_harbor_config.yaml')
|
||||
assert.equal(payload.project_run?.run_id, 'run-modern')
|
||||
assert.equal(payload.project_dossier?.open_issues?.[0], 'Need QA sign-off')
|
||||
assert.equal(payload.seat_digests?.[0]?.seat_id, 'seat-2')
|
||||
assert.equal(payload.revision_links?.[0]?.link_type, 'revision_of')
|
||||
|
||||
const view = getRuntimeOrgView(payload)
|
||||
assert.equal(view.runtimeTeams[0]?.cell_id, 'cell-2')
|
||||
assert.equal(view.runtimeSeats[0]?.role_session_id, 'seat-2')
|
||||
assert.equal(view.workItems[0]?.work_item_id, 'wi-2')
|
||||
assert.equal(view.workItems[0]?.adaptive?.normalized_state, 'waiting_for_gate')
|
||||
assert.equal(view.frontier.status, 'paused')
|
||||
assert.equal(view.projectRun?.current_revision, 2)
|
||||
assert.equal(view.projectDossier?.latest_deliverable_summary, 'Ship candidate ready')
|
||||
assert.equal(view.seatDigests[0]?.seat_id, 'seat-2')
|
||||
assert.equal(view.revisionLinks[0]?.link_type, 'revision_of')
|
||||
|
||||
console.log('runtimeOrg contract checks passed')
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
OrgInfoPayload,
|
||||
ProjectDossierInfo,
|
||||
ProjectRunInfo,
|
||||
RuntimeFrontierSummary,
|
||||
RuntimeSeatInfo,
|
||||
RuntimeTeamInfo,
|
||||
RuntimeWorkItemInfo,
|
||||
SeatDigestInfo,
|
||||
SessionLinkInfo,
|
||||
} from '../types/visual'
|
||||
|
||||
type RecordLike = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is RecordLike {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function asRecordArray<T extends RecordLike = RecordLike>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? (value.filter(isRecord) as T[]) : []
|
||||
}
|
||||
|
||||
function asStringList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => String(item ?? '').trim()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function asRuntimeFrontierSummary(value: unknown): RuntimeFrontierSummary {
|
||||
if (!isRecord(value)) return {}
|
||||
return {
|
||||
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
|
||||
status: typeof value.status === 'string' ? value.status : undefined,
|
||||
total_cells: typeof value.total_cells === 'number' ? value.total_cells : undefined,
|
||||
total_role_sessions: typeof value.total_role_sessions === 'number' ? value.total_role_sessions : undefined,
|
||||
total_work_items: typeof value.total_work_items === 'number' ? value.total_work_items : undefined,
|
||||
ready_count: typeof value.ready_count === 'number' ? value.ready_count : undefined,
|
||||
running_count: typeof value.running_count === 'number' ? value.running_count : undefined,
|
||||
blocked_count: typeof value.blocked_count === 'number' ? value.blocked_count : undefined,
|
||||
waiting_count: typeof value.waiting_count === 'number' ? value.waiting_count : undefined,
|
||||
done_count: typeof value.done_count === 'number' ? value.done_count : undefined,
|
||||
failed_count: typeof value.failed_count === 'number' ? value.failed_count : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapRuntimeTeam(raw: RecordLike): RuntimeTeamInfo {
|
||||
return {
|
||||
cell_id: String(raw.cell_id ?? raw.team_id ?? raw.id ?? '').trim(),
|
||||
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
|
||||
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
|
||||
manager_role_id: String(raw.manager_role_id ?? '').trim(),
|
||||
member_role_ids: asStringList(raw.member_role_ids),
|
||||
seat_ids: asStringList(raw.seat_ids),
|
||||
parent_team_id: typeof raw.parent_team_id === 'string' ? raw.parent_team_id : undefined,
|
||||
status: String(raw.status ?? 'idle'),
|
||||
is_final_decider_cell: typeof raw.is_final_decider_cell === 'boolean' ? raw.is_final_decider_cell : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapRuntimeSeat(raw: RecordLike): RuntimeSeatInfo {
|
||||
return {
|
||||
role_session_id: String(raw.role_session_id ?? raw.id ?? '').trim(),
|
||||
role_id: String(raw.role_id ?? '').trim(),
|
||||
employee_id: String(raw.employee_id ?? '').trim(),
|
||||
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
|
||||
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
|
||||
seat_id: typeof raw.seat_id === 'string' ? raw.seat_id : undefined,
|
||||
focused_work_item_id: typeof raw.focused_work_item_id === 'string' ? raw.focused_work_item_id : undefined,
|
||||
current_work_item_id: typeof raw.current_work_item_id === 'string' ? raw.current_work_item_id : undefined,
|
||||
background_work_item_ids: asStringList(raw.background_work_item_ids),
|
||||
manager_role_ids: asStringList(raw.manager_role_ids),
|
||||
manager_seat_id: typeof raw.manager_seat_id === 'string' ? raw.manager_seat_id : undefined,
|
||||
resident_status: typeof raw.resident_status === 'string' ? raw.resident_status : undefined,
|
||||
latest_notification: isRecord(raw.latest_notification) ? raw.latest_notification : undefined,
|
||||
manager_digest: isRecord(raw.manager_digest) ? raw.manager_digest : undefined,
|
||||
status: String(raw.status ?? 'cold'),
|
||||
}
|
||||
}
|
||||
|
||||
function mapRuntimeWorkItem(raw: RecordLike): RuntimeWorkItemInfo {
|
||||
const metadata = isRecord(raw.metadata) ? raw.metadata : undefined
|
||||
const adaptive = isRecord(raw.adaptive)
|
||||
? raw.adaptive
|
||||
: metadata && isRecord(metadata.adaptive)
|
||||
? metadata.adaptive
|
||||
: undefined
|
||||
return {
|
||||
work_item_id: String(raw.work_item_id ?? raw.id ?? '').trim(),
|
||||
role_id: String(raw.role_id ?? '').trim(),
|
||||
cell_id: String(raw.cell_id ?? '').trim(),
|
||||
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
|
||||
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
|
||||
seat_id: typeof raw.seat_id === 'string' ? raw.seat_id : undefined,
|
||||
title: String(raw.title ?? ''),
|
||||
kind: String(raw.kind ?? 'execute'),
|
||||
phase: String(raw.phase ?? 'ready'),
|
||||
kanban_column: String(raw.kanban_column ?? 'todo'),
|
||||
batch_id: typeof raw.batch_id === 'string' ? raw.batch_id : undefined,
|
||||
batch_index: typeof raw.batch_index === 'number' ? raw.batch_index : undefined,
|
||||
deliverable_summary: typeof raw.deliverable_summary === 'string' ? raw.deliverable_summary : undefined,
|
||||
blocked_reason: typeof raw.blocked_reason === 'string' ? raw.blocked_reason : undefined,
|
||||
handoff_status: typeof raw.handoff_status === 'string' ? raw.handoff_status : undefined,
|
||||
parent_work_item_id: typeof raw.parent_work_item_id === 'string' ? raw.parent_work_item_id : undefined,
|
||||
work_item_projection_id: typeof raw.work_item_projection_id === 'string' ? raw.work_item_projection_id : undefined,
|
||||
metadata,
|
||||
adaptive,
|
||||
}
|
||||
}
|
||||
|
||||
function asProjectRunInfo(value: unknown): ProjectRunInfo | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
return {
|
||||
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
|
||||
project_id: typeof value.project_id === 'string' ? value.project_id : undefined,
|
||||
session_id: typeof value.session_id === 'string' ? value.session_id : undefined,
|
||||
status: typeof value.status === 'string' ? value.status : undefined,
|
||||
lifecycle_status: typeof value.lifecycle_status === 'string' ? value.lifecycle_status : undefined,
|
||||
company_profile: typeof value.company_profile === 'string' ? value.company_profile : undefined,
|
||||
execution_model: typeof value.execution_model === 'string' ? value.execution_model : undefined,
|
||||
current_revision: typeof value.current_revision === 'number' ? value.current_revision : undefined,
|
||||
latest_deliverable_summary: typeof value.latest_deliverable_summary === 'string' ? value.latest_deliverable_summary : undefined,
|
||||
recovery_pointer: isRecord(value.recovery_pointer) ? value.recovery_pointer : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function asProjectDossierInfo(value: unknown): ProjectDossierInfo | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
return {
|
||||
project_id: typeof value.project_id === 'string' ? value.project_id : undefined,
|
||||
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
|
||||
latest_deliverable_summary: typeof value.latest_deliverable_summary === 'string' ? value.latest_deliverable_summary : undefined,
|
||||
architecture_decisions: asRecordArray(value.architecture_decisions),
|
||||
completed_work_items: asRecordArray(value.completed_work_items),
|
||||
open_issues: asStringList(value.open_issues),
|
||||
verification_summary: typeof value.verification_summary === 'string' ? value.verification_summary : undefined,
|
||||
artifact_index: asRecordArray(value.artifact_index),
|
||||
handoff_summaries: asRecordArray(value.handoff_summaries),
|
||||
last_failure_summary: typeof value.last_failure_summary === 'string' ? value.last_failure_summary : undefined,
|
||||
project_memory_excerpt: typeof value.project_memory_excerpt === 'string' ? value.project_memory_excerpt : undefined,
|
||||
session_memory_excerpt: typeof value.session_memory_excerpt === 'string' ? value.session_memory_excerpt : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSeatDigest(raw: RecordLike): SeatDigestInfo {
|
||||
return {
|
||||
seat_id: String(raw.seat_id ?? raw.id ?? '').trim(),
|
||||
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
|
||||
role_id: typeof raw.role_id === 'string' ? raw.role_id : undefined,
|
||||
employee_id: typeof raw.employee_id === 'string' ? raw.employee_id : undefined,
|
||||
role_session_id: typeof raw.role_session_id === 'string' ? raw.role_session_id : undefined,
|
||||
resident_status: typeof raw.resident_status === 'string' ? raw.resident_status : undefined,
|
||||
current_work_item: isRecord(raw.current_work_item) ? raw.current_work_item : undefined,
|
||||
latest_notification: isRecord(raw.latest_notification) ? raw.latest_notification : undefined,
|
||||
manager_digest: isRecord(raw.manager_digest) ? raw.manager_digest : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSessionLink(raw: RecordLike): SessionLinkInfo {
|
||||
return {
|
||||
link_id: typeof raw.link_id === 'string' ? raw.link_id : undefined,
|
||||
session_id: typeof raw.session_id === 'string' ? raw.session_id : undefined,
|
||||
linked_session_id: typeof raw.linked_session_id === 'string' ? raw.linked_session_id : undefined,
|
||||
link_type: typeof raw.link_type === 'string' ? raw.link_type : undefined,
|
||||
metadata: isRecord(raw.metadata) ? raw.metadata : undefined,
|
||||
created_at: typeof raw.created_at === 'string' ? raw.created_at : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOrgInfoPayload(raw: OrgInfoPayload | RecordLike | null | undefined): OrgInfoPayload {
|
||||
const source = isRecord(raw) ? raw : {}
|
||||
const runtimeTeams = asRecordArray(source.runtime_teams).map(mapRuntimeTeam)
|
||||
const runtimeSeats = asRecordArray(source.runtime_seats).map(mapRuntimeSeat)
|
||||
const workItems = asRecordArray(source.work_items).map(mapRuntimeWorkItem)
|
||||
|
||||
return {
|
||||
roles: Array.isArray(source.roles) ? source.roles as OrgInfoPayload['roles'] : [],
|
||||
employees: Array.isArray(source.employees) ? source.employees as OrgInfoPayload['employees'] : [],
|
||||
company_profile: typeof source.company_profile === 'string' ? source.company_profile : '',
|
||||
organization_id: typeof source.organization_id === 'string' ? source.organization_id : undefined,
|
||||
organization_name: typeof source.organization_name === 'string' ? source.organization_name : undefined,
|
||||
organization_config_file: typeof source.organization_config_file === 'string' ? source.organization_config_file : undefined,
|
||||
final_decider_role_id: typeof source.final_decider_role_id === 'string' ? source.final_decider_role_id : null,
|
||||
top_level_role_ids: asStringList(source.top_level_role_ids),
|
||||
runtime_teams: runtimeTeams,
|
||||
runtime_seats: runtimeSeats,
|
||||
work_items: workItems,
|
||||
frontier: asRuntimeFrontierSummary(source.frontier),
|
||||
project_run: asProjectRunInfo(source.project_run),
|
||||
project_dossier: asProjectDossierInfo(source.project_dossier),
|
||||
seat_digests: asRecordArray(source.seat_digests).map(mapSeatDigest),
|
||||
revision_links: asRecordArray(source.revision_links).map(mapSessionLink),
|
||||
project_recovery: isRecord(source.project_recovery) ? source.project_recovery : undefined,
|
||||
channels: Array.isArray(source.channels) ? source.channels as OrgInfoPayload['channels'] : [],
|
||||
connectors: Array.isArray(source.connectors) ? source.connectors as OrgInfoPayload['connectors'] : [],
|
||||
org_version: typeof source.org_version === 'number' ? source.org_version : 0,
|
||||
runtime_topology_version: typeof source.runtime_topology_version === 'number' ? source.runtime_topology_version : 0,
|
||||
installed_packages: Array.isArray(source.installed_packages) ? source.installed_packages as NonNullable<OrgInfoPayload['installed_packages']> : [],
|
||||
runtime_policy: isRecord(source.runtime_policy) ? source.runtime_policy as NonNullable<OrgInfoPayload['runtime_policy']> : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuntimeOrgView {
|
||||
runtimeTeams: RuntimeTeamInfo[]
|
||||
runtimeSeats: RuntimeSeatInfo[]
|
||||
workItems: RuntimeWorkItemInfo[]
|
||||
frontier: RuntimeFrontierSummary
|
||||
projectRun?: ProjectRunInfo
|
||||
projectDossier?: ProjectDossierInfo
|
||||
seatDigests: SeatDigestInfo[]
|
||||
revisionLinks: SessionLinkInfo[]
|
||||
projectRecovery?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function getRuntimeOrgView(payload: OrgInfoPayload | null | undefined): RuntimeOrgView {
|
||||
const normalized = normalizeOrgInfoPayload(payload)
|
||||
return {
|
||||
runtimeTeams: normalized.runtime_teams ?? [],
|
||||
runtimeSeats: normalized.runtime_seats ?? [],
|
||||
workItems: normalized.work_items ?? [],
|
||||
frontier: normalized.frontier ?? {},
|
||||
projectRun: normalized.project_run,
|
||||
projectDossier: normalized.project_dossier,
|
||||
seatDigests: normalized.seat_digests ?? [],
|
||||
revisionLinks: normalized.revision_links ?? [],
|
||||
projectRecovery: normalized.project_recovery,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Session } from '../types/kanban'
|
||||
|
||||
export type CanonicalSessionExecMode = 'task' | 'company' | 'org'
|
||||
export type CanonicalCompanyProfile = 'corporate' | 'custom'
|
||||
|
||||
export function normalizeSessionExecMode(value?: string | null): CanonicalSessionExecMode {
|
||||
const normalized = String(value ?? '').trim().toLowerCase()
|
||||
if (normalized === 'company') return 'company'
|
||||
if (normalized === 'org' || normalized === 'custom') return 'org'
|
||||
return 'task'
|
||||
}
|
||||
|
||||
export function normalizeSessionCompanyProfile(value?: string | null): CanonicalCompanyProfile {
|
||||
return String(value ?? '').trim().toLowerCase() === 'custom' ? 'custom' : 'corporate'
|
||||
}
|
||||
|
||||
export function canonicalizeSessionExecutionIdentity<T extends Partial<Session>>(session: T): T {
|
||||
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
|
||||
const rawProfile = String(session.companyProfile ?? '').trim().toLowerCase()
|
||||
const rawOrgId = String(session.orgId ?? '').trim()
|
||||
const hasExplicitMode = rawMode.length > 0
|
||||
|
||||
const execMode: CanonicalSessionExecMode = hasExplicitMode
|
||||
? normalizeSessionExecMode(rawMode)
|
||||
: (rawProfile === 'custom' || rawOrgId ? 'org' : normalizeSessionExecMode(rawMode))
|
||||
|
||||
if (execMode === 'org') {
|
||||
return {
|
||||
...session,
|
||||
execMode: 'org',
|
||||
companyProfile: 'custom',
|
||||
orgId: rawOrgId || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (execMode === 'company') {
|
||||
return {
|
||||
...session,
|
||||
execMode: 'company',
|
||||
companyProfile: 'corporate',
|
||||
orgId: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
execMode: 'task',
|
||||
companyProfile: undefined,
|
||||
orgId: undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ChatMessage } from '../types/chat'
|
||||
|
||||
/** Channel id that buckets a session's chat messages. */
|
||||
export function sessionChannelId(taskId: string): string {
|
||||
return `session:${taskId}`
|
||||
}
|
||||
|
||||
const RECRUITMENT_CHECKPOINT_TYPES = new Set([
|
||||
'company_recruitment_confirmation',
|
||||
'company_staffing_selection',
|
||||
])
|
||||
|
||||
/**
|
||||
* Map `role_id -> recruited person/template display names` for a single chat
|
||||
* session, derived from that session's latest recruitment checkpoint message.
|
||||
*
|
||||
* This is display-only plumbing: org_info / `data.employees` is global (the
|
||||
* project's active run / accumulated org config), so it cannot represent the
|
||||
* recruitment of an arbitrary *selected* session. The recruitment a user
|
||||
* confirmed in a session is persisted as a chat checkpoint message, which is
|
||||
* the only per-session source the frontend already has.
|
||||
*
|
||||
* Returns `null` when the session has no recruitment checkpoint loaded, so the
|
||||
* caller can fall back to the global employee list (previous behaviour).
|
||||
*/
|
||||
export function extractSessionRecruitmentByRole(
|
||||
messages: ChatMessage[],
|
||||
): Record<string, string[]> | null {
|
||||
let latest: ChatMessage | null = null
|
||||
for (const m of messages) {
|
||||
const ct = m.metadata?.checkpoint_type
|
||||
if (!ct || !RECRUITMENT_CHECKPOINT_TYPES.has(ct)) continue
|
||||
if (!latest || (m.timestamp ?? 0) >= (latest.timestamp ?? 0)) latest = m
|
||||
}
|
||||
if (!latest?.metadata) return null
|
||||
|
||||
const meta = latest.metadata
|
||||
const map: Record<string, string[]> = {}
|
||||
const push = (roleId: unknown, name: unknown) => {
|
||||
const r = String(roleId ?? '').trim()
|
||||
const n = String(name ?? '').trim()
|
||||
if (!r || !n) return
|
||||
const arr = map[r] ?? (map[r] = [])
|
||||
if (!arr.includes(n)) arr.push(n)
|
||||
}
|
||||
|
||||
// Preferred: recruitment_rationales is already a flat per-role display label
|
||||
// (this is exactly the "(role_id, role, recruited name, reason)" list the
|
||||
// user sees in the recruitment confirmation panel).
|
||||
for (const r of meta.recruitment_rationales ?? []) {
|
||||
if (r?.selection_label) push(r.role_id, r.selection_label)
|
||||
}
|
||||
// Fallback: derive a name from the structured proposals when rationales are
|
||||
// absent (older payloads / staffing-only checkpoints).
|
||||
if (Object.keys(map).length === 0) {
|
||||
for (const p of meta.proposals ?? []) {
|
||||
const name =
|
||||
p?.existing_employee?.employee_name ||
|
||||
p?.candidate?.proposed_name ||
|
||||
p?.candidate?.template_name ||
|
||||
''
|
||||
push(p?.role_id, name)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(map).length ? map : null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import type { Session } from '../types/kanban'
|
||||
import {
|
||||
companyRuntimeControlPatchForBoardStatus,
|
||||
isCompanyRuntimeSession,
|
||||
} from './sessionRuntime'
|
||||
|
||||
function makeSession(overrides: Partial<Session>): Partial<Session> {
|
||||
return {
|
||||
taskId: 'task-a',
|
||||
status: 'idle',
|
||||
execMode: 'task',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
isCompanyRuntimeSession(makeSession({ execMode: 'org' })),
|
||||
true,
|
||||
'org sessions are company runtime sessions',
|
||||
)
|
||||
assert.equal(
|
||||
isCompanyRuntimeSession(makeSession({ execMode: 'company' })),
|
||||
true,
|
||||
'company sessions are company runtime sessions',
|
||||
)
|
||||
assert.equal(
|
||||
isCompanyRuntimeSession(makeSession({ parentSessionId: 'root-session' })),
|
||||
true,
|
||||
'child runtime sessions are company runtime sessions',
|
||||
)
|
||||
assert.equal(
|
||||
isCompanyRuntimeSession(makeSession({ execMode: 'task', companyProfile: undefined })),
|
||||
false,
|
||||
'plain task sessions are not company runtime sessions',
|
||||
)
|
||||
assert.equal(
|
||||
isCompanyRuntimeSession(makeSession({ execMode: 'task', companyProfile: 'corporate' })),
|
||||
false,
|
||||
'explicit task sessions with legacy default companyProfile stay plain task sessions',
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
companyRuntimeControlPatchForBoardStatus(makeSession({ execMode: 'org' }), 'running'),
|
||||
{ runtimeControlState: 'running', canStop: true, canResume: false },
|
||||
'company running board events must show Stop immediately',
|
||||
)
|
||||
assert.deepEqual(
|
||||
companyRuntimeControlPatchForBoardStatus(
|
||||
makeSession({ execMode: 'org', runtimeControlState: 'running', canStop: true }),
|
||||
'done',
|
||||
),
|
||||
{ runtimeControlState: 'idle', canStop: false, canResume: false },
|
||||
'company terminal board events must clear Stop after completion',
|
||||
)
|
||||
assert.deepEqual(
|
||||
companyRuntimeControlPatchForBoardStatus(
|
||||
makeSession({ execMode: 'org', runtimeControlState: 'suspended', canResume: true }),
|
||||
'cancelled',
|
||||
),
|
||||
{},
|
||||
'terminal child events must not erase an explicit suspended/continue state',
|
||||
)
|
||||
assert.deepEqual(
|
||||
companyRuntimeControlPatchForBoardStatus(makeSession({ execMode: 'task' }), 'running'),
|
||||
{},
|
||||
'plain task board events must not opt into company runtime control',
|
||||
)
|
||||
|
||||
console.log('sessionRuntime.test.ts: OK (company board status drives Stop without breaking Continue)')
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentAnimStatus, Session } from '../types/kanban'
|
||||
|
||||
export const TERMINAL_SESSION_STATUSES = new Set(['done', 'failed', 'cancelled'])
|
||||
const COMPANY_EXEC_MODES = new Set(['company', 'org', 'custom'])
|
||||
const BOARD_STATUS_IDLE = new Set(['idle', 'done', 'failed', 'cancelled'])
|
||||
|
||||
export function getSessionRuntimeStatus(
|
||||
session: Pick<Session, 'status' | 'agentStatus' | 'runtimeControlState'>,
|
||||
): AgentAnimStatus {
|
||||
if (TERMINAL_SESSION_STATUSES.has(session.status)) return 'idle'
|
||||
if (session.agentStatus === 'reflecting' || session.agentStatus === 'tool_active') {
|
||||
return session.agentStatus
|
||||
}
|
||||
if (
|
||||
session.runtimeControlState
|
||||
&& session.runtimeControlState !== 'running'
|
||||
&& session.runtimeControlState !== 'suspending'
|
||||
&& session.runtimeControlState !== 'resuming'
|
||||
) {
|
||||
return 'idle'
|
||||
}
|
||||
if (session.status === 'running') return 'reflecting'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function isSessionWorking(
|
||||
session: Pick<Session, 'status' | 'agentStatus' | 'runtimeControlState'>,
|
||||
): boolean {
|
||||
return getSessionRuntimeStatus(session) !== 'idle'
|
||||
}
|
||||
|
||||
export function isCompanyRuntimeSession(session?: Partial<Session> | null): boolean {
|
||||
if (!session) return false
|
||||
const mode = String(session.execMode ?? '').trim().toLowerCase()
|
||||
const hasExplicitTaskMode = mode === 'task'
|
||||
return COMPANY_EXEC_MODES.has(mode)
|
||||
|| !!session.isCompanyRuntime
|
||||
|| !!session.parentSessionId
|
||||
|| !!session.orgId
|
||||
|| !!session.workItemProjectionId
|
||||
|| !!session.roleWorkItems
|
||||
|| !!session.executorRoleWorkItems
|
||||
|| (!!session.companyProfile && !hasExplicitTaskMode)
|
||||
}
|
||||
|
||||
export function companyRuntimeControlPatchForBoardStatus(
|
||||
session: Partial<Session> | undefined | null,
|
||||
status: string,
|
||||
): Partial<Pick<Session, 'runtimeControlState' | 'canStop' | 'canResume'>> {
|
||||
if (!isCompanyRuntimeSession(session)) return {}
|
||||
|
||||
const normalizedStatus = String(status ?? '').trim().toLowerCase()
|
||||
if (normalizedStatus === 'running') {
|
||||
return {
|
||||
runtimeControlState: 'running',
|
||||
canStop: true,
|
||||
canResume: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (BOARD_STATUS_IDLE.has(normalizedStatus)) {
|
||||
const existingState = session?.runtimeControlState
|
||||
if (existingState === 'suspending' || existingState === 'suspended') {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
runtimeControlState: 'idle',
|
||||
canStop: false,
|
||||
canResume: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { compactSessionTitle } from './sessionTitle'
|
||||
|
||||
assert.equal(
|
||||
compactSessionTitle('one two three four five six seven eight nine ten eleven'),
|
||||
'one two three four five six seven eight nine ten...',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
compactSessionTitle('请你帮我设计实现一个后端管理系统'),
|
||||
'请你帮我设计实现一个...',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
compactSessionTitle('Build API 请实现登录流程 and tests now'),
|
||||
'Build API 请实现登录流程 and...',
|
||||
)
|
||||
|
||||
assert.equal(compactSessionTitle('Short title'), 'Short title')
|
||||
assert.equal(compactSessionTitle(' '), 'New Chat')
|
||||
|
||||
console.log('sessionTitle.test.ts: OK (session titles compact to 10 mixed units)')
|
||||
@@ -0,0 +1,66 @@
|
||||
function isCjkCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x3400 && codePoint <= 0x4dbf)
|
||||
|| (codePoint >= 0x4e00 && codePoint <= 0x9fff)
|
||||
|| (codePoint >= 0xf900 && codePoint <= 0xfaff)
|
||||
|| (codePoint >= 0x3040 && codePoint <= 0x30ff)
|
||||
|| (codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||
)
|
||||
}
|
||||
|
||||
function isCjkChar(ch: string): boolean {
|
||||
const codePoint = ch.codePointAt(0)
|
||||
return typeof codePoint === 'number' && isCjkCodePoint(codePoint)
|
||||
}
|
||||
|
||||
function isWordChar(ch: string): boolean {
|
||||
return !isCjkChar(ch) && /^[\p{L}\p{N}_]$/u.test(ch)
|
||||
}
|
||||
|
||||
export function compactSessionTitle(input: string, maxUnits = 10, fallback = 'New Chat'): string {
|
||||
const text = String(input || '').replace(/\s+/g, ' ').trim()
|
||||
if (!text || maxUnits <= 0) return fallback
|
||||
|
||||
let units = 0
|
||||
let index = 0
|
||||
let cutIndex = text.length
|
||||
|
||||
while (index < text.length) {
|
||||
const codePoint = text.codePointAt(index)
|
||||
if (typeof codePoint !== 'number') break
|
||||
const ch = String.fromCodePoint(codePoint)
|
||||
if (/\s/.test(ch)) {
|
||||
index += ch.length
|
||||
continue
|
||||
}
|
||||
if (isCjkChar(ch)) {
|
||||
units += 1
|
||||
index += ch.length
|
||||
if (units === maxUnits) {
|
||||
cutIndex = index
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isWordChar(ch)) {
|
||||
while (index < text.length) {
|
||||
const innerCodePoint = text.codePointAt(index)
|
||||
if (typeof innerCodePoint !== 'number') break
|
||||
const innerChar = String.fromCodePoint(innerCodePoint)
|
||||
if (!isWordChar(innerChar)) break
|
||||
index += innerChar.length
|
||||
}
|
||||
units += 1
|
||||
if (units === maxUnits) {
|
||||
cutIndex = index
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
index += ch.length
|
||||
}
|
||||
|
||||
const compact = text.slice(0, cutIndex).trim() || fallback
|
||||
const hasMoreUnits = Array.from(text.slice(cutIndex)).some(ch => isCjkChar(ch) || isWordChar(ch))
|
||||
return hasMoreUnits ? `${compact}...` : compact
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ChatStoreState } from '../chat/ChatStore'
|
||||
import type { KanbanTask } from '../types/kanban'
|
||||
|
||||
export function notifyTaskAssigned(
|
||||
chatStore: ChatStoreState,
|
||||
task: KanbanTask,
|
||||
agentNames: string[],
|
||||
officeChannelId?: string,
|
||||
) {
|
||||
const names = agentNames.join(', ')
|
||||
const channelId = officeChannelId ?? `session:${task.id}`
|
||||
const targetCh = chatStore.channels.find(ch => ch.id === channelId) ?? chatStore.channels.find(ch => ch.type === 'activity')
|
||||
if (!targetCh) return
|
||||
chatStore.sendMessage({
|
||||
channelId: targetCh.id,
|
||||
sender: 'system',
|
||||
senderName: 'System',
|
||||
content: `Task **${task.displayId}** assigned to ${names}`,
|
||||
metadata: { type: 'task_assigned', taskId: task.id, boardId: task.boardId },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { EmployeeAssignment } from '../types/kanban'
|
||||
|
||||
type WorkItemIdentity = {
|
||||
workItemRoleId?: string
|
||||
workItemRoleName?: string
|
||||
employeeAssignment?: EmployeeAssignment
|
||||
}
|
||||
|
||||
export function humanizeWorkItemRoleId(value?: string): string {
|
||||
const normalized = (value ?? '').trim()
|
||||
if (!normalized) return ''
|
||||
return normalized
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
export function getWorkItemRoleLabel(identity: WorkItemIdentity): string {
|
||||
const explicit = (identity.workItemRoleName ?? '').trim()
|
||||
if (explicit) return explicit
|
||||
return humanizeWorkItemRoleId(identity.workItemRoleId)
|
||||
}
|
||||
|
||||
export function getWorkItemEmployeeLabel(identity: WorkItemIdentity): string {
|
||||
return (identity.employeeAssignment?.name ?? '').trim()
|
||||
}
|
||||
|
||||
export function getWorkItemAssignmentLabel(identity: WorkItemIdentity): string {
|
||||
const role = getWorkItemRoleLabel(identity)
|
||||
const employee = getWorkItemEmployeeLabel(identity)
|
||||
if (role && employee) return `${role} · ${employee}`
|
||||
return role || employee
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mapBackendSession, mapBackendTask } from './collabSync'
|
||||
import { getExecutionTurnId, getLinkedRuntimeTaskId, getWorkItemCardId } from './workItemRuntimeIds'
|
||||
|
||||
const workItemCard = mapBackendTask({
|
||||
task_id: 'wi-1',
|
||||
work_item_id: 'wi-1',
|
||||
title: 'Prepare launch plan',
|
||||
kanban_column: 'in-progress',
|
||||
runtime_task_id: 'runtime-task-1',
|
||||
execution_turn_id: 'runtime-task-1',
|
||||
})
|
||||
|
||||
assert.equal(getWorkItemCardId(workItemCard), 'wi-1')
|
||||
assert.equal(getLinkedRuntimeTaskId(workItemCard), 'runtime-task-1')
|
||||
assert.equal(workItemCard.runtimeTaskId, 'runtime-task-1')
|
||||
assert.equal(workItemCard.executionTurnId, 'runtime-task-1')
|
||||
|
||||
const canonicalCard = mapBackendTask({
|
||||
work_item_id: 'wi-2',
|
||||
title: 'Review launch plan',
|
||||
kanban_column: 'in-review',
|
||||
runtime_task_id: 'runtime-task-2',
|
||||
execution_turn_id: 'runtime-task-2',
|
||||
})
|
||||
|
||||
assert.equal(getWorkItemCardId(canonicalCard), 'wi-2')
|
||||
assert.equal(getLinkedRuntimeTaskId(canonicalCard), 'runtime-task-2')
|
||||
|
||||
const plainTaskCard = mapBackendTask({
|
||||
task_id: 'plain-task-1',
|
||||
title: 'Plain task',
|
||||
kanban_column: 'todo',
|
||||
runtime_task_id: 'plain-task-1',
|
||||
})
|
||||
|
||||
assert.equal(plainTaskCard.runtimeTaskId, 'plain-task-1')
|
||||
assert.equal(getLinkedRuntimeTaskId(plainTaskCard), '')
|
||||
|
||||
const runtimeSession = mapBackendSession({
|
||||
task_id: 'legacy-task-id',
|
||||
runtime_task_id: 'runtime-task-3',
|
||||
execution_turn_id: 'turn-3',
|
||||
channel_id: 'session:runtime-task-3',
|
||||
title: 'CTO Execution Turn',
|
||||
})
|
||||
|
||||
assert.equal(runtimeSession.taskId, 'legacy-task-id')
|
||||
assert.equal(runtimeSession.runtimeTaskId, 'runtime-task-3')
|
||||
assert.equal(runtimeSession.executionTurnId, 'turn-3')
|
||||
assert.equal(getExecutionTurnId(runtimeSession), 'turn-3')
|
||||
|
||||
const taskSessionWithRuntimeParentMetadata = mapBackendSession({
|
||||
project_id: 'default',
|
||||
task_id: 'task-mode-session',
|
||||
session_id: 'task-session-id',
|
||||
parent_session_id: 'task-session-id',
|
||||
channel_id: 'session:task-mode-session',
|
||||
title: 'Task mode session detail',
|
||||
exec_mode: 'task',
|
||||
})
|
||||
|
||||
assert.equal(taskSessionWithRuntimeParentMetadata.execMode, 'task')
|
||||
assert.equal(taskSessionWithRuntimeParentMetadata.mode, 'primary')
|
||||
assert.equal(taskSessionWithRuntimeParentMetadata.parentSessionId, undefined)
|
||||
|
||||
console.log('workItemRuntimeIds alias checks passed')
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { KanbanTask, Session, WorkItemProgressEntry } from '../types/kanban'
|
||||
|
||||
type WorkItemCardLike = Pick<KanbanTask, 'id' | 'workItemId'>
|
||||
|
||||
type ExecutionTurnLike =
|
||||
| Pick<Session, 'taskId' | 'runtimeTaskId' | 'executionTurnId'>
|
||||
| Pick<WorkItemProgressEntry, 'runtimeTaskId' | 'executionTurnId'>
|
||||
|
||||
type LinkedRuntimeLike = Pick<KanbanTask, 'runtimeTaskId' | 'executionTurnId'>
|
||||
& Pick<KanbanTask, 'workItemId'>
|
||||
|
||||
function clean(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export function getWorkItemCardId(card: WorkItemCardLike | null | undefined): string {
|
||||
return clean(card?.workItemId) || clean(card?.id)
|
||||
}
|
||||
|
||||
export function getExecutionTurnId(turn: ExecutionTurnLike | null | undefined): string {
|
||||
if (!turn) return ''
|
||||
const raw = turn as Partial<Session & WorkItemProgressEntry>
|
||||
return (
|
||||
clean(raw.executionTurnId)
|
||||
|| clean(raw.runtimeTaskId)
|
||||
|| clean(raw.taskId)
|
||||
)
|
||||
}
|
||||
|
||||
export function getLinkedRuntimeTaskId(card: LinkedRuntimeLike | null | undefined): string {
|
||||
return clean(card?.workItemId)
|
||||
? clean(card?.executionTurnId) || clean(card?.runtimeTaskId)
|
||||
: ''
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ChatMessage } from '../types/chat'
|
||||
import type { Session } from '../types/kanban'
|
||||
import { mapBackendSession } from './collabSync'
|
||||
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
|
||||
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation } from './workItemSessions'
|
||||
|
||||
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
|
||||
return {
|
||||
projectId: 'test-project',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const parent = makeSession({
|
||||
taskId: 'root-task',
|
||||
channelId: 'session:root-task',
|
||||
title: 'Root',
|
||||
status: 'running',
|
||||
columnId: 'in-progress',
|
||||
assigneeIds: [],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 10,
|
||||
messageCount: 1,
|
||||
mode: 'primary',
|
||||
originTaskId: 'root-task',
|
||||
})
|
||||
|
||||
const child = makeSession({
|
||||
taskId: 'child-task',
|
||||
channelId: 'session:child-task',
|
||||
title: 'CEO Intake',
|
||||
status: 'done',
|
||||
columnId: 'done',
|
||||
assigneeIds: ['ceo'],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: 2,
|
||||
updatedAt: 11,
|
||||
messageCount: 2,
|
||||
mode: 'child',
|
||||
parentSessionId: 'parent-session-id-that-has-not-arrived-yet',
|
||||
originTaskId: 'root-task',
|
||||
})
|
||||
|
||||
const matches = getWorkItemChildSessions(parent, [parent, child])
|
||||
assert.equal(matches.length, 1)
|
||||
assert.equal(matches[0]?.taskId, 'child-task')
|
||||
|
||||
const companyParent = makeSession({
|
||||
taskId: 'company-root',
|
||||
channelId: 'session:company-root',
|
||||
sessionId: 'company-root-session',
|
||||
title: 'Work-Item Runtime Root',
|
||||
status: 'running',
|
||||
columnId: 'in-progress',
|
||||
assigneeIds: [],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 10,
|
||||
messageCount: 1,
|
||||
mode: 'primary',
|
||||
execMode: 'company',
|
||||
originTaskId: 'company-root',
|
||||
})
|
||||
|
||||
const companyChild = makeSession({
|
||||
taskId: 'company-child',
|
||||
channelId: 'session:company-child',
|
||||
title: 'CTO Delegation',
|
||||
status: 'running',
|
||||
columnId: 'in-progress',
|
||||
assigneeIds: ['cto'],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: 2,
|
||||
updatedAt: 25,
|
||||
messageCount: 4,
|
||||
mode: 'child',
|
||||
parentSessionId: 'company-root-session',
|
||||
originTaskId: 'company-root',
|
||||
})
|
||||
|
||||
const companyProjection = projectSessionConversation(companyParent, [companyChild])
|
||||
assert.deepEqual(
|
||||
companyProjection.timelineSessions.map((session) => session.taskId),
|
||||
['company-root', 'company-child'],
|
||||
)
|
||||
assert.equal(companyProjection.displaySession?.taskId, 'company-root')
|
||||
assert.equal(companyProjection.runtimeSession?.taskId, 'company-child')
|
||||
assert.equal(companyProjection.projectedFromChild, false)
|
||||
|
||||
const idleChildWithNewerInboxTimestamp = makeSession({
|
||||
...companyChild,
|
||||
taskId: 'company-idle-child',
|
||||
channelId: 'session:company-idle-child',
|
||||
title: 'Idle Child Inbox Update',
|
||||
updatedAt: 1_000_000,
|
||||
messageCount: 0,
|
||||
progressLog: [],
|
||||
})
|
||||
const stableCompanyProjection = projectSessionConversation(companyParent, [idleChildWithNewerInboxTimestamp])
|
||||
assert.equal(stableCompanyProjection.runtimeSession?.taskId, 'company-root')
|
||||
|
||||
const companyHeaderView = getConversationHeaderSession(
|
||||
{
|
||||
...companyParent,
|
||||
workItemRoleName: 'CEO',
|
||||
employeeAssignment: {
|
||||
name: 'Root Employee',
|
||||
category: 'leadership',
|
||||
},
|
||||
},
|
||||
{
|
||||
...companyChild,
|
||||
workItemRoleName: 'CTO',
|
||||
contextTokens: 0,
|
||||
contextWindow: 128000,
|
||||
inputTokens: 11,
|
||||
outputTokens: 22,
|
||||
totalTokens: 33,
|
||||
turnCostUsd: 0.001,
|
||||
sessionCostUsd: 0.002,
|
||||
selectedExecutionAgent: 'codex',
|
||||
employeeAssignment: {
|
||||
name: 'Child Employee',
|
||||
category: 'engineering',
|
||||
},
|
||||
},
|
||||
[companyParent, companyChild],
|
||||
)
|
||||
assert.equal(companyHeaderView?.taskId, 'company-root')
|
||||
assert.equal(companyHeaderView?.workItemRoleName, 'CEO')
|
||||
assert.equal(companyHeaderView?.employeeAssignment?.name, 'Root Employee')
|
||||
|
||||
const resultMessage = (id: string, channelId: string, content: string, metadata: ChatMessage['metadata'], sender = 'chao'): ChatMessage => ({
|
||||
id,
|
||||
channelId,
|
||||
sender,
|
||||
senderName: sender === 'system' ? 'Company Member' : 'Chao',
|
||||
content,
|
||||
timestamp: 1000 + id.length,
|
||||
mentions: [],
|
||||
metadata,
|
||||
})
|
||||
|
||||
const finalBody = 'Final delivery is ready with a long enough body to be considered the same user-visible result across transcript mirrors and worker notifications.'
|
||||
const mergedDeliveryMessages = mergeConversationMessages([
|
||||
[
|
||||
resultMessage(
|
||||
'opc-top-level',
|
||||
'session:company-root',
|
||||
finalBody,
|
||||
{ source: 'engine', transcript_kind: 'top_level_reply' },
|
||||
'assistant',
|
||||
),
|
||||
],
|
||||
[
|
||||
resultMessage(
|
||||
'parent-mirror',
|
||||
'session:company-root',
|
||||
`**Deliver final result to user: Chao Intake**: ${finalBody}`,
|
||||
{ source: 'engine', transcript_kind: 'child_result' },
|
||||
),
|
||||
],
|
||||
[
|
||||
resultMessage(
|
||||
'child-direct',
|
||||
'session:company-child',
|
||||
finalBody,
|
||||
{ source: 'engine', transcript_kind: 'child_task_result' },
|
||||
),
|
||||
resultMessage(
|
||||
'worker-note',
|
||||
'session:company-child',
|
||||
finalBody,
|
||||
{ source: 'runtime_event', kind: 'worker_notification', notification_kind: 'task_complete' },
|
||||
'system',
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
assert.equal(mergedDeliveryMessages.length, 1)
|
||||
assert.equal(mergedDeliveryMessages[0]?.id, 'child-direct')
|
||||
assert.equal(companyHeaderView?.status, 'running')
|
||||
assert.equal(companyHeaderView?.contextTokens, 0)
|
||||
assert.equal(companyHeaderView?.contextWindow, 128000)
|
||||
assert.equal(companyHeaderView?.inputTokens, 11)
|
||||
assert.equal(companyHeaderView?.outputTokens, 22)
|
||||
assert.equal(companyHeaderView?.totalTokens, 33)
|
||||
assert.equal(companyHeaderView?.turnCostUsd, 0.001)
|
||||
assert.equal(companyHeaderView?.sessionCostUsd, 0.002)
|
||||
assert.equal(companyHeaderView?.selectedExecutionAgent, 'codex')
|
||||
|
||||
const customOrgRoot = makeSession({
|
||||
taskId: 'custom-root',
|
||||
channelId: 'session:custom-root',
|
||||
sessionId: 'custom-root-session',
|
||||
title: 'Custom Work-Item Runtime Root',
|
||||
status: 'running',
|
||||
columnId: 'in-progress',
|
||||
assigneeIds: ['chief_architect'],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 30,
|
||||
messageCount: 1,
|
||||
mode: 'primary',
|
||||
execMode: 'custom',
|
||||
isCompanyRuntime: true,
|
||||
originTaskId: 'custom-root',
|
||||
workItemRoleId: 'chief_architect',
|
||||
workItemRoleName: 'Chief Architect',
|
||||
})
|
||||
|
||||
const customOrgChild = makeSession({
|
||||
taskId: 'custom-child',
|
||||
channelId: 'session:custom-child',
|
||||
title: 'Research Lead Turn',
|
||||
status: 'running',
|
||||
columnId: 'in-progress',
|
||||
assigneeIds: ['research_lead'],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: 2,
|
||||
updatedAt: 31,
|
||||
messageCount: 2,
|
||||
mode: 'child',
|
||||
parentSessionId: 'custom-root-session',
|
||||
originTaskId: 'custom-root',
|
||||
workItemRoleId: 'research_lead',
|
||||
workItemRoleName: 'Research Lead',
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
getWorkItemChildSessions(customOrgRoot, [customOrgRoot, customOrgChild]).map((session) => session.taskId),
|
||||
['custom-child'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
getWorkItemRoleSessions(customOrgRoot, [customOrgRoot, customOrgChild]).map((session) => session.workItemRoleName),
|
||||
['Chief Architect', 'Research Lead'],
|
||||
)
|
||||
|
||||
const mergedCustomView = getConversationSessionView(
|
||||
{
|
||||
...customOrgRoot,
|
||||
status: 'failed',
|
||||
roleWorkItems: {
|
||||
chief_architect: {
|
||||
roleKey: 'chief_architect',
|
||||
roleId: 'chief_architect',
|
||||
roleName: 'Chief Architect',
|
||||
runtimeStatus: 'idle',
|
||||
aggregatedStatus: 'active',
|
||||
workItems: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...customOrgChild,
|
||||
runtimeControlState: 'running',
|
||||
canStop: true,
|
||||
},
|
||||
[customOrgRoot, customOrgChild],
|
||||
)
|
||||
assert.equal(mergedCustomView?.runtimeControlState, 'running')
|
||||
assert.equal(mergedCustomView?.canStop, true)
|
||||
assert.equal(mergedCustomView?.roleWorkItems?.chief_architect.roleName, 'Chief Architect')
|
||||
assert.equal(mergedCustomView?.status, 'running')
|
||||
assert.equal(deriveCompanyRuntimeDisplayStatus(mergedCustomView), 'running')
|
||||
|
||||
const failedCustomView = getConversationSessionView(
|
||||
{
|
||||
...customOrgRoot,
|
||||
status: 'failed',
|
||||
roleWorkItems: {
|
||||
chief_architect: {
|
||||
roleKey: 'chief_architect',
|
||||
roleId: 'chief_architect',
|
||||
roleName: 'Chief Architect',
|
||||
runtimeStatus: 'idle',
|
||||
aggregatedStatus: 'failed',
|
||||
workItems: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
[],
|
||||
)
|
||||
assert.equal(failedCustomView?.status, 'failed')
|
||||
|
||||
const runtimeRollupOverridesStaleActiveView = getConversationSessionView(
|
||||
{
|
||||
...customOrgRoot,
|
||||
status: 'failed',
|
||||
roleWorkItems: {
|
||||
chief_architect: {
|
||||
roleKey: 'chief_architect',
|
||||
roleId: 'chief_architect',
|
||||
roleName: 'Chief Architect',
|
||||
runtimeStatus: 'idle',
|
||||
aggregatedStatus: 'failed',
|
||||
workItems: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...customOrgRoot,
|
||||
status: 'failed',
|
||||
roleWorkItems: {
|
||||
chief_architect: {
|
||||
roleKey: 'chief_architect',
|
||||
roleId: 'chief_architect',
|
||||
roleName: 'Chief Architect',
|
||||
runtimeStatus: 'tool_active',
|
||||
aggregatedStatus: 'active',
|
||||
workItems: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
)
|
||||
assert.equal(runtimeRollupOverridesStaleActiveView?.status, 'running')
|
||||
|
||||
const companyIdentity = canonicalizeSessionExecutionIdentity({
|
||||
taskId: 'company-stale-org',
|
||||
execMode: 'company',
|
||||
companyProfile: 'custom',
|
||||
orgId: 'quantum_harbor',
|
||||
})
|
||||
assert.equal(companyIdentity.execMode, 'company')
|
||||
assert.equal(companyIdentity.companyProfile, 'corporate')
|
||||
assert.equal(companyIdentity.orgId, undefined)
|
||||
|
||||
const customIdentity = canonicalizeSessionExecutionIdentity({
|
||||
taskId: 'custom-org',
|
||||
execMode: 'org',
|
||||
companyProfile: 'corporate',
|
||||
orgId: 'quantum_harbor',
|
||||
})
|
||||
assert.equal(customIdentity.execMode, 'org')
|
||||
assert.equal(customIdentity.companyProfile, 'custom')
|
||||
assert.equal(customIdentity.orgId, 'quantum_harbor')
|
||||
|
||||
const mappedCompanySession = mapBackendSession({
|
||||
task_id: 'mapped-company',
|
||||
channel_id: 'session:mapped-company',
|
||||
title: 'Mapped Company',
|
||||
status: 'running',
|
||||
column_id: 'in-progress',
|
||||
assignee_ids: [],
|
||||
tags: [],
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
exec_mode: 'company',
|
||||
company_profile: 'custom',
|
||||
org_id: 'quantum_harbor',
|
||||
})
|
||||
assert.equal(mappedCompanySession.execMode, 'company')
|
||||
assert.equal(mappedCompanySession.companyProfile, 'corporate')
|
||||
assert.equal(mappedCompanySession.orgId, undefined)
|
||||
|
||||
console.log('workItemSessions origin-task linking checks passed')
|
||||
@@ -0,0 +1,507 @@
|
||||
import type { ChatMessage } from '../types/chat'
|
||||
import type { ProgressEntry, Session } from '../types/kanban'
|
||||
import { getContextUsageMetrics } from './contextUsage'
|
||||
import { isSessionWorking } from './sessionRuntime'
|
||||
|
||||
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
|
||||
const USED_PCT_RE = /(\d{1,3})%\s*used/i
|
||||
const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i
|
||||
|
||||
function compactWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function stripNarrativeTitlePrefix(content: string): string {
|
||||
const trimmed = String(content || '').trim()
|
||||
const markdownTitle = trimmed.match(/^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$/)
|
||||
if (markdownTitle) {
|
||||
const body = markdownTitle[2].trim()
|
||||
if (body.length >= 80) return body
|
||||
}
|
||||
const colonIndex = trimmed.indexOf(': ')
|
||||
if (colonIndex < 8 || colonIndex > 160) return trimmed
|
||||
|
||||
const prefix = trimmed.slice(0, colonIndex).replace(/\*/g, '').trim()
|
||||
const body = trimmed.slice(colonIndex + 2).trim()
|
||||
if (body.length < 80) return trimmed
|
||||
if (!/[A-Za-z\u4e00-\u9fff]/.test(prefix)) return trimmed
|
||||
if (/^(https?|file)$/i.test(prefix)) return trimmed
|
||||
return body
|
||||
}
|
||||
|
||||
function resultSurfacePriority(message: ChatMessage): number {
|
||||
const meta = (message.metadata ?? {}) as Record<string, unknown>
|
||||
const transcriptKind = String(meta.transcript_kind ?? meta.kind ?? '').trim()
|
||||
switch (transcriptKind) {
|
||||
case 'child_task_result':
|
||||
return 80
|
||||
case 'child_task_result_retry':
|
||||
return 79
|
||||
case 'company_role_result':
|
||||
return 75
|
||||
case 'company_role_result_retry':
|
||||
return 74
|
||||
case 'child_result':
|
||||
return 70
|
||||
case 'runtime_v2_assistant':
|
||||
return 60
|
||||
case 'runtime_v2_company_assistant':
|
||||
return 20
|
||||
case 'top_level_reply':
|
||||
return 40
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (String(meta.kind ?? '').trim() === 'worker_notification') {
|
||||
return 20
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function resultSurfaceDedupeKey(message: ChatMessage): string {
|
||||
if (resultSurfacePriority(message) <= 0) return ''
|
||||
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
|
||||
return content ? `result:${content}` : ''
|
||||
}
|
||||
|
||||
function parseProgressNumber(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined
|
||||
const normalized = value.replace(/,/g, '').trim()
|
||||
if (!normalized) return undefined
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : undefined
|
||||
}
|
||||
|
||||
function clampPct(value: number | undefined): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.min(Math.round(value), 100))
|
||||
}
|
||||
|
||||
export function deriveCompanyRuntimeDisplayStatus(session: Session | null | undefined): string | undefined {
|
||||
if (!session) return undefined
|
||||
const execMode = String(session.execMode ?? '').trim()
|
||||
const isCompanyLike = (
|
||||
execMode === 'company'
|
||||
|| execMode === 'org'
|
||||
|| execMode === 'custom'
|
||||
|| !!session.isCompanyRuntime
|
||||
|| !!session.roleWorkItems
|
||||
|| !!session.executorRoleWorkItems
|
||||
)
|
||||
if (!isCompanyLike) return undefined
|
||||
const summaries = Object.values(session.executorRoleWorkItems ?? session.roleWorkItems ?? {})
|
||||
if (summaries.length === 0) return undefined
|
||||
const statuses = summaries.map(summary => summary.aggregatedStatus)
|
||||
if (statuses.some(status => status === 'active')) return 'running'
|
||||
if (statuses.some(status => status === 'waiting')) return 'pending'
|
||||
if (statuses.some(status => status === 'pending')) return 'pending'
|
||||
if (statuses.every(status => status === 'failed')) return 'failed'
|
||||
if (statuses.every(status => status === 'done')) return 'done'
|
||||
if (statuses.some(status => status === 'done')) return 'done'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function deriveContextFromProgressLog(progressLog: ProgressEntry[] | undefined): {
|
||||
contextTokens?: number
|
||||
contextWindow?: number
|
||||
contextRemainingPct?: number
|
||||
} {
|
||||
const derived: {
|
||||
contextTokens?: number
|
||||
contextWindow?: number
|
||||
contextRemainingPct?: number
|
||||
} = {}
|
||||
|
||||
for (const entry of progressLog ?? []) {
|
||||
const text = `${entry.summary ?? ''} ${entry.detail ?? ''}`.trim()
|
||||
if (!text) continue
|
||||
|
||||
const tokenMatch = CONTEXT_TOKENS_RE.exec(text)
|
||||
if (tokenMatch) {
|
||||
const usedTokens = parseProgressNumber(tokenMatch[1])
|
||||
const windowTokens = parseProgressNumber(tokenMatch[2])
|
||||
if (typeof usedTokens === 'number') derived.contextTokens = usedTokens
|
||||
if (typeof windowTokens === 'number' && windowTokens > 0) derived.contextWindow = windowTokens
|
||||
}
|
||||
|
||||
const remainingMatch = REMAINING_PCT_RE.exec(text)
|
||||
if (remainingMatch) {
|
||||
derived.contextRemainingPct = clampPct(parseProgressNumber(remainingMatch[1]))
|
||||
continue
|
||||
}
|
||||
|
||||
const usedMatch = USED_PCT_RE.exec(text)
|
||||
if (usedMatch) {
|
||||
const usedPct = clampPct(parseProgressNumber(usedMatch[1]))
|
||||
if (typeof usedPct === 'number') derived.contextRemainingPct = 100 - usedPct
|
||||
}
|
||||
}
|
||||
|
||||
return derived
|
||||
}
|
||||
|
||||
function withDerivedSessionRuntime(session: Session): Session {
|
||||
const derivedContext = deriveContextFromProgressLog(session.progressLog)
|
||||
const contextTokens = session.contextTokens ?? derivedContext.contextTokens
|
||||
const contextWindow = session.contextWindow ?? derivedContext.contextWindow
|
||||
const contextRemainingPct = session.contextRemainingPct ?? derivedContext.contextRemainingPct
|
||||
|
||||
if (
|
||||
contextTokens === session.contextTokens
|
||||
&& contextWindow === session.contextWindow
|
||||
&& contextRemainingPct === session.contextRemainingPct
|
||||
) {
|
||||
return session
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
contextTokens,
|
||||
contextWindow,
|
||||
contextRemainingPct,
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueSessionsByTaskId(sessions: Session[]): Session[] {
|
||||
const seen = new Set<string>()
|
||||
const unique: Session[] = []
|
||||
for (const session of sessions) {
|
||||
if (seen.has(session.taskId)) continue
|
||||
seen.add(session.taskId)
|
||||
unique.push(session)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
function hasWorkItemRoleIdentity(session: Session | null | undefined): boolean {
|
||||
return !!(
|
||||
String(session?.workItemRoleId ?? '').trim()
|
||||
|| String(session?.workItemRoleName ?? '').trim()
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkItemChildSessions(activeSession: Session | null, sessions: Session[]): Session[] {
|
||||
if (!activeSession || activeSession.mode === 'child') return []
|
||||
|
||||
const parentKeys = new Set<string>()
|
||||
if (activeSession.sessionId) parentKeys.add(activeSession.sessionId)
|
||||
parentKeys.add(activeSession.taskId)
|
||||
const activeOriginTaskId = String(activeSession.originTaskId ?? activeSession.taskId ?? '').trim()
|
||||
|
||||
const executionTurnOrder = new Map<string, number>()
|
||||
for (const entry of activeSession.workItemLog ?? []) {
|
||||
const taskId = entry.executionTurnId || entry.runtimeTaskId
|
||||
if (taskId && !executionTurnOrder.has(taskId)) {
|
||||
executionTurnOrder.set(taskId, executionTurnOrder.size)
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const matches = sessions.filter((session) => {
|
||||
if (session.taskId === activeSession.taskId) return false
|
||||
if (seen.has(session.taskId)) return false
|
||||
|
||||
const linkedByParent = !!session.parentSessionId && parentKeys.has(session.parentSessionId)
|
||||
const linkedByWorkItem = executionTurnOrder.has(session.taskId)
|
||||
const linkedByOrigin = !!activeOriginTaskId && String(session.originTaskId ?? '').trim() === activeOriginTaskId
|
||||
if (!linkedByParent && !linkedByWorkItem && !linkedByOrigin) return false
|
||||
|
||||
seen.add(session.taskId)
|
||||
return true
|
||||
})
|
||||
|
||||
return matches.sort((a, b) => {
|
||||
const aOrder = executionTurnOrder.get(a.taskId)
|
||||
const bOrder = executionTurnOrder.get(b.taskId)
|
||||
if (aOrder != null && bOrder != null && aOrder !== bOrder) return aOrder - bOrder
|
||||
if (aOrder != null && bOrder == null) return -1
|
||||
if (aOrder == null && bOrder != null) return 1
|
||||
return b.updatedAt - a.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
export function getWorkItemRoleSessions(activeSession: Session | null, sessions: Session[]): Session[] {
|
||||
const childSessions = getWorkItemChildSessions(activeSession, sessions)
|
||||
if (!activeSession || activeSession.mode === 'child') return childSessions
|
||||
if (!hasWorkItemRoleIdentity(activeSession)) return childSessions
|
||||
|
||||
return uniqueSessionsByTaskId([activeSession, ...childSessions])
|
||||
}
|
||||
|
||||
export function getConversationPeerSessions(activeSession: Session | null, sessions: Session[]): Session[] {
|
||||
if (!activeSession || activeSession.mode === 'child') return []
|
||||
const activeSessionId = String(activeSession.sessionId ?? '').trim()
|
||||
if (!activeSessionId) return []
|
||||
|
||||
const seen = new Set<string>()
|
||||
return sessions.filter((session) => {
|
||||
if (session.taskId === activeSession.taskId) return false
|
||||
if (seen.has(session.taskId)) return false
|
||||
if (String(session.sessionId ?? '').trim() !== activeSessionId) return false
|
||||
seen.add(session.taskId)
|
||||
return true
|
||||
}).sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
function isSummaryConversationSession(session: Session | null | undefined): boolean {
|
||||
const execMode = String(session?.execMode ?? '').trim()
|
||||
return execMode === 'company' || execMode === 'org' || execMode === 'custom'
|
||||
}
|
||||
|
||||
function projectedDisplayScore(session: Session): number {
|
||||
const workingBonus = isSessionWorking(session) ? 1_000_000_000 : 0
|
||||
const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0
|
||||
const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000
|
||||
const progressBonus = (session.progressLog?.length ?? 0) * 100
|
||||
return draftBonus + workingBonus + messageBonus + progressBonus + session.updatedAt
|
||||
}
|
||||
|
||||
function runtimeProjectionScore(session: Session): number {
|
||||
const contextUsage = getContextUsageMetrics(session)
|
||||
const workingBonus = isSessionWorking(session) ? 100_000_000_000 : 0
|
||||
const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0
|
||||
const contextBonus = (
|
||||
typeof contextUsage.usedPct === 'number'
|
||||
|| typeof contextUsage.usedTokens === 'number'
|
||||
|| typeof contextUsage.windowTokens === 'number'
|
||||
) ? 10_000_000_000 : 0
|
||||
const tokenBonus = (
|
||||
typeof session.inputTokens === 'number'
|
||||
|| typeof session.outputTokens === 'number'
|
||||
|| typeof session.totalTokens === 'number'
|
||||
) ? 1_000_000_000 : 0
|
||||
const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000
|
||||
const progressBonus = (session.progressLog?.length ?? 0) * 100
|
||||
return workingBonus + draftBonus + contextBonus + tokenBonus + messageBonus + progressBonus
|
||||
}
|
||||
|
||||
export interface SessionConversationProjection {
|
||||
timelineSessions: Session[]
|
||||
displaySession: Session | null
|
||||
runtimeSession: Session | null
|
||||
projectedFromChild: boolean
|
||||
}
|
||||
|
||||
export function projectSessionConversation(
|
||||
activeSession: Session | null,
|
||||
relatedSessions: Session[],
|
||||
): SessionConversationProjection {
|
||||
if (!activeSession) {
|
||||
return {
|
||||
timelineSessions: [],
|
||||
displaySession: null,
|
||||
runtimeSession: null,
|
||||
projectedFromChild: false,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
|
||||
if (activeSession.mode === 'child' || relatedSessions.length === 0) {
|
||||
return {
|
||||
timelineSessions: [normalizedActiveSession],
|
||||
displaySession: normalizedActiveSession,
|
||||
runtimeSession: normalizedActiveSession,
|
||||
projectedFromChild: false,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRelatedSessions = relatedSessions.map(withDerivedSessionRuntime)
|
||||
const visibleRelatedSessions = normalizedRelatedSessions.filter((session) => session.status !== 'cancelled')
|
||||
const projectedSessions = visibleRelatedSessions.length > 0 ? visibleRelatedSessions : normalizedRelatedSessions
|
||||
const timelineSessions = uniqueSessionsByTaskId([normalizedActiveSession, ...projectedSessions])
|
||||
const summaryConversation = isSummaryConversationSession(activeSession)
|
||||
const projectedDisplaySession = [...timelineSessions].sort(
|
||||
(a, b) => projectedDisplayScore(b) - projectedDisplayScore(a),
|
||||
)[0] ?? normalizedActiveSession
|
||||
const displaySession = summaryConversation
|
||||
? normalizedActiveSession
|
||||
: projectedDisplaySession
|
||||
const runtimeSession = [...timelineSessions].sort(
|
||||
(a, b) => runtimeProjectionScore(b) - runtimeProjectionScore(a),
|
||||
)[0] ?? displaySession
|
||||
|
||||
return {
|
||||
timelineSessions,
|
||||
displaySession,
|
||||
runtimeSession,
|
||||
projectedFromChild: !summaryConversation && displaySession.taskId !== activeSession.taskId,
|
||||
}
|
||||
}
|
||||
|
||||
export function getConversationSessionView(
|
||||
activeSession: Session | null,
|
||||
runtimeSession: Session | null,
|
||||
timelineSessions: Session[],
|
||||
): Session | null {
|
||||
if (!activeSession) return null
|
||||
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
|
||||
const runtimeSource = runtimeSession ?? normalizedActiveSession
|
||||
const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource)
|
||||
?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession)
|
||||
const mergedMessageCount = getConversationMessageCount(
|
||||
timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession],
|
||||
)
|
||||
|
||||
return {
|
||||
...normalizedActiveSession,
|
||||
status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status),
|
||||
assigneeIds: runtimeSource.assigneeIds.length > 0
|
||||
? runtimeSource.assigneeIds
|
||||
: normalizedActiveSession.assigneeIds,
|
||||
agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus,
|
||||
currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool,
|
||||
displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool,
|
||||
toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs,
|
||||
lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary,
|
||||
contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens,
|
||||
contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow,
|
||||
contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct,
|
||||
inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens,
|
||||
outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens,
|
||||
totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens,
|
||||
turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd,
|
||||
sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd,
|
||||
pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount,
|
||||
drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode,
|
||||
workItemProjectionId: runtimeSource.workItemProjectionId ?? normalizedActiveSession.workItemProjectionId,
|
||||
workItemTurnType: runtimeSource.workItemTurnType ?? normalizedActiveSession.workItemTurnType,
|
||||
companyProfile: normalizedActiveSession.companyProfile ?? runtimeSource.companyProfile,
|
||||
workItemRoleId: normalizedActiveSession.workItemRoleId ?? runtimeSource.workItemRoleId,
|
||||
workItemRoleName: normalizedActiveSession.workItemRoleName ?? runtimeSource.workItemRoleName,
|
||||
workItemGate: normalizedActiveSession.workItemGate ?? runtimeSource.workItemGate,
|
||||
employeeAssignment: normalizedActiveSession.employeeAssignment ?? runtimeSource.employeeAssignment,
|
||||
selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent,
|
||||
draftAssistantText: runtimeSource.draftAssistantText ?? normalizedActiveSession.draftAssistantText,
|
||||
draftUpdatedAt: runtimeSource.draftUpdatedAt ?? normalizedActiveSession.draftUpdatedAt,
|
||||
draftIteration: runtimeSource.draftIteration ?? normalizedActiveSession.draftIteration,
|
||||
draftTurnId: runtimeSource.draftTurnId ?? normalizedActiveSession.draftTurnId,
|
||||
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
|
||||
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
|
||||
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
|
||||
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
|
||||
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
|
||||
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
|
||||
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
|
||||
updatedAt: Math.max(
|
||||
normalizedActiveSession.updatedAt,
|
||||
runtimeSource.updatedAt,
|
||||
),
|
||||
messageCount: Math.max(
|
||||
normalizedActiveSession.messageCount ?? 0,
|
||||
runtimeSource.messageCount ?? 0,
|
||||
mergedMessageCount,
|
||||
),
|
||||
isCompanyRuntime: !!(normalizedActiveSession.isCompanyRuntime || runtimeSource.isCompanyRuntime),
|
||||
workItemLog: (normalizedActiveSession.workItemLog?.length ?? 0) > 0
|
||||
? normalizedActiveSession.workItemLog
|
||||
: runtimeSource.workItemLog,
|
||||
roleWorkItems: normalizedActiveSession.roleWorkItems ?? runtimeSource.roleWorkItems,
|
||||
executorRoleWorkItems: normalizedActiveSession.executorRoleWorkItems ?? runtimeSource.executorRoleWorkItems,
|
||||
activeSubagents: normalizedActiveSession.activeSubagents ?? runtimeSource.activeSubagents,
|
||||
permissionRequests: normalizedActiveSession.permissionRequests ?? runtimeSource.permissionRequests,
|
||||
}
|
||||
}
|
||||
|
||||
export function getConversationHeaderSession(
|
||||
activeSession: Session | null,
|
||||
runtimeSession: Session | null,
|
||||
timelineSessions: Session[],
|
||||
): Session | null {
|
||||
if (!activeSession) return null
|
||||
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
|
||||
const runtimeSource = runtimeSession ?? normalizedActiveSession
|
||||
const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource)
|
||||
?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession)
|
||||
const mergedMessageCount = getConversationMessageCount(
|
||||
timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession],
|
||||
)
|
||||
|
||||
return {
|
||||
...normalizedActiveSession,
|
||||
status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status),
|
||||
agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus,
|
||||
currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool,
|
||||
displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool,
|
||||
toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs,
|
||||
lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary,
|
||||
contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens,
|
||||
contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow,
|
||||
contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct,
|
||||
inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens,
|
||||
outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens,
|
||||
totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens,
|
||||
turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd,
|
||||
sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd,
|
||||
pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount,
|
||||
drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode,
|
||||
selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent,
|
||||
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
|
||||
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
|
||||
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
|
||||
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
|
||||
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
|
||||
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
|
||||
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
|
||||
updatedAt: Math.max(normalizedActiveSession.updatedAt, runtimeSource.updatedAt),
|
||||
messageCount: Math.max(
|
||||
normalizedActiveSession.messageCount ?? 0,
|
||||
runtimeSource.messageCount ?? 0,
|
||||
mergedMessageCount,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
|
||||
const seen = new Set<string>()
|
||||
const resultKeyIndex = new Map<string, number>()
|
||||
const merged: ChatMessage[] = []
|
||||
for (const group of messageGroups) {
|
||||
for (const message of group) {
|
||||
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
||||
const uiMessageId = typeof metadata.ui_message_id === 'string'
|
||||
? metadata.ui_message_id.trim()
|
||||
: ''
|
||||
const resultKey = resultSurfaceDedupeKey(message)
|
||||
if (resultKey) {
|
||||
const existingIndex = resultKeyIndex.get(resultKey)
|
||||
if (existingIndex !== undefined) {
|
||||
if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) {
|
||||
merged[existingIndex] = message
|
||||
}
|
||||
continue
|
||||
}
|
||||
resultKeyIndex.set(resultKey, merged.length)
|
||||
}
|
||||
const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
|
||||
if (seen.has(dedupeKey)) continue
|
||||
seen.add(dedupeKey)
|
||||
merged.push(message)
|
||||
}
|
||||
}
|
||||
return merged.sort((a, b) => (
|
||||
a.timestamp === b.timestamp
|
||||
? a.id.localeCompare(b.id)
|
||||
: a.timestamp - b.timestamp
|
||||
))
|
||||
}
|
||||
|
||||
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
|
||||
const seen = new Set<string>()
|
||||
const merged: ProgressEntry[] = []
|
||||
for (const session of timelineSessions) {
|
||||
for (const entry of session.progressLog ?? []) {
|
||||
const dedupeKey = `${entry.timestamp}:${entry.type}:${entry.summary}:${entry.detail ?? ''}`
|
||||
if (seen.has(dedupeKey)) continue
|
||||
seen.add(dedupeKey)
|
||||
merged.push(entry)
|
||||
}
|
||||
}
|
||||
return merged.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
|
||||
export function getConversationMessageCount(timelineSessions: Session[]): number {
|
||||
return timelineSessions.reduce(
|
||||
(total, session) => total + Math.max(0, session.messageCount ?? 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
import type {
|
||||
AgentRuntimePayload,
|
||||
EmployeeDetailPayload,
|
||||
KanbanViewDataPayload,
|
||||
OrgInfoPayload,
|
||||
OrgCreateMemberInput,
|
||||
OrgSavedCreatePayload,
|
||||
ReorgListPayload,
|
||||
SavedOrgSummary,
|
||||
SessionProgressPayload,
|
||||
SocketEnvelope,
|
||||
SocketStatus,
|
||||
TalentListPayload,
|
||||
VisualEvent,
|
||||
VisualSnapshot,
|
||||
WorkerNotificationPayload,
|
||||
WorkItemProgressPayload,
|
||||
} from '../types/visual'
|
||||
import type { CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
|
||||
import type { TaskPreferredAgent } from '../types/kanban'
|
||||
|
||||
interface SocketHandlers {
|
||||
onSnapshot?: (snapshot: VisualSnapshot) => void
|
||||
onEvent?: (event: VisualEvent) => void
|
||||
onAck?: (payload: Record<string, unknown>) => void
|
||||
onStatus?: (status: SocketStatus, detail?: string) => void
|
||||
onChannelCreated?: (payload: { channel_id: string; name: string; channel_type: string; participants: string[] }) => void
|
||||
onBoardEvent?: (payload: Record<string, unknown>) => void
|
||||
onCrossOfficeCollab?: (payload: { agent_ids: string[]; task_id: string; action: string }) => void
|
||||
onCollabMessage?: (type: string, payload: Record<string, unknown>) => void
|
||||
onAgentRuntimeUpdate?: (payload: AgentRuntimePayload) => void
|
||||
onWorkerNotification?: (payload: WorkerNotificationPayload) => void
|
||||
onKanbanViewData?: (payload: KanbanViewDataPayload) => void
|
||||
onSessionCreated?: (payload: { project_id: string; task_id: string; channel_id: string; session_id?: string; parent_session_id?: string; origin_task_id?: string; title: string; status: string; created_at: number; assignee_ids?: string[]; exec_mode?: string; company_profile?: string; org_id?: string; organization_id?: string; preferred_agent?: TaskPreferredAgent; selected_execution_agent?: TaskPreferredAgent }) => void
|
||||
onSessionUpdated?: (payload: { project_id: string; task_id: string; exec_mode?: string; company_profile?: string; org_id?: string; organization_id?: string; preferred_agent?: TaskPreferredAgent; selected_execution_agent?: TaskPreferredAgent }) => void
|
||||
onSessionMessage?: (payload: Record<string, unknown>) => void
|
||||
onSessionTitleUpdated?: (payload: { project_id: string; task_id: string; title: string }) => void
|
||||
onSessionDeleted?: (payload: { project_id: string; task_id: string }) => void
|
||||
onSessionProgress?: (payload: SessionProgressPayload) => void
|
||||
onChildSessionCreated?: (payload: { project_id: string; session_id: string; parent_session_id: string; task_id: string; origin_task_id?: string; title: string; agent_id?: string; org_id?: string; organization_id?: string; selected_execution_agent?: TaskPreferredAgent }) => void
|
||||
onProjectSwitched?: (payload: { project_id: string; switch_seq?: string }) => void
|
||||
onProjectDeleted?: (payload: { project_id: string }) => void
|
||||
onOrgInfo?: (payload: OrgInfoPayload) => void
|
||||
onRecoveryStatus?: (payload: any) => void
|
||||
onRecoveryResult?: (payload: any) => void
|
||||
onTalentList?: (payload: TalentListPayload) => void
|
||||
onTalentScanLocal?: (payload: { templates: Array<{ template_id: string; name: string; description: string; category: string; domains: string[]; tags: string[] }> }) => void
|
||||
onEmployeeDetail?: (payload: EmployeeDetailPayload) => void
|
||||
onReorgList?: (payload: ReorgListPayload) => void
|
||||
onWorkItemProgress?: (payload: WorkItemProgressPayload) => void
|
||||
onMarketListInstalled?: (payload: { packages: Array<Record<string, unknown>> }) => void
|
||||
onMarketBrowse?: (payload: { presets: Array<Record<string, unknown>> }) => void
|
||||
onMarketPreview?: (payload: Record<string, unknown>) => void
|
||||
onOrgConfigExport?: (payload: { yaml: string }) => void
|
||||
onOrgConfigImport?: (payload: { ok: boolean; dry_run?: boolean; preview?: { roles_added: number; roles_removed: number; employees_changed: number }; error?: string; validation_errors?: string[] }) => void
|
||||
onOrgSavedList?: (payload: { orgs: SavedOrgSummary[]; active_name?: string | null }) => void
|
||||
onOrgSavedSaveAs?: (payload: { ok: boolean; name: string; error?: string }) => void
|
||||
onOrgSavedCreate?: (payload: OrgSavedCreatePayload) => void
|
||||
onOrgSavedLoad?: (payload: { ok: boolean; name: string; error?: string }) => void
|
||||
onOrgSavedDelete?: (payload: { ok: boolean; name: string; error?: string }) => void
|
||||
onCommsState?: (payload: CommsStatePayload) => void
|
||||
onCommsMessage?: (payload: CommsMessagePayload) => void
|
||||
}
|
||||
|
||||
export interface CommsMessageItem {
|
||||
message_id: string
|
||||
from: string
|
||||
to?: string
|
||||
subject: string
|
||||
sent_at: string
|
||||
blocking: boolean
|
||||
path: string
|
||||
bucket?: 'new' | 'seen' | 'sent'
|
||||
}
|
||||
|
||||
/** @deprecated Use CommsMessageItem instead */
|
||||
export type CommsRecentUnread = CommsMessageItem
|
||||
|
||||
export interface CommsRolePayload {
|
||||
role_id: string
|
||||
unread_count: number
|
||||
has_blocking: boolean
|
||||
seen_count: number
|
||||
outbox_count: number
|
||||
recent_unread: CommsMessageItem[]
|
||||
recent_seen?: CommsMessageItem[]
|
||||
recent_outbox?: CommsMessageItem[]
|
||||
}
|
||||
|
||||
export interface CommsMeetingPayload {
|
||||
meeting_id: string
|
||||
topic: string
|
||||
status: string
|
||||
organizer: string
|
||||
participants: string[]
|
||||
entry_count: number
|
||||
opened_at: string
|
||||
closed_at?: string | null
|
||||
decision?: string | null
|
||||
transcript_path: string
|
||||
}
|
||||
|
||||
export interface CommsFailurePayload {
|
||||
operation: string
|
||||
from_role: string
|
||||
to_role: string
|
||||
reason: string
|
||||
attempted_path?: string
|
||||
attempted_command?: string
|
||||
recorded_at?: string
|
||||
attempt_count?: number
|
||||
can_retry?: boolean
|
||||
}
|
||||
|
||||
export interface CommsStatePayload {
|
||||
available: boolean
|
||||
reason?: string
|
||||
empty?: boolean
|
||||
project_id?: string
|
||||
session_id?: string
|
||||
workspace_root?: string
|
||||
output_root?: string
|
||||
comms_root?: string
|
||||
projection_status?: string
|
||||
recent_failures?: CommsFailurePayload[]
|
||||
roles?: CommsRolePayload[]
|
||||
meetings?: CommsMeetingPayload[]
|
||||
}
|
||||
|
||||
export interface CommsMessagePayload {
|
||||
project_id: string
|
||||
path: string
|
||||
header: {
|
||||
from?: string
|
||||
to?: string
|
||||
sent_at?: string
|
||||
blocking?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
body: string
|
||||
}
|
||||
|
||||
const RECONNECT_BASE_MS = 2000
|
||||
const RECONNECT_MAX_MS = 30000
|
||||
const RECONNECT_MAX_ATTEMPTS = 20
|
||||
const PENDING_QUEUE_MAX = 100
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||
const HEARTBEAT_TIMEOUT_MS = 10_000
|
||||
const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
|
||||
'collab_sync',
|
||||
'kanban_create_board',
|
||||
'kanban_create_task',
|
||||
'kanban_update_task',
|
||||
'kanban_move_task',
|
||||
'kanban_delete_board',
|
||||
'kanban_delete_task',
|
||||
'kanban_assign',
|
||||
'kanban_status',
|
||||
'kanban_switch_view',
|
||||
'run_task',
|
||||
'create_session',
|
||||
'session_send',
|
||||
'session_update_config',
|
||||
'session_delete',
|
||||
'session_detail',
|
||||
'session_stop',
|
||||
'session_resume',
|
||||
'session_complete',
|
||||
'session_update_title',
|
||||
'secretary_send',
|
||||
'project_index',
|
||||
'recovery_action',
|
||||
'comms_state',
|
||||
'comms_read_message',
|
||||
])
|
||||
|
||||
export class VisualSocketClient {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectTimer: number | null = null
|
||||
private closedByUser = false
|
||||
private reconnectAttempt = 0
|
||||
private pendingQueue: string[] = []
|
||||
private heartbeatTimer: number | null = null
|
||||
private pongTimer: number | null = null
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private handlers: SocketHandlers,
|
||||
) {}
|
||||
|
||||
updateUrl(url: string): void {
|
||||
this.url = url
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.closedByUser = false
|
||||
this.handlers.onStatus?.('connecting')
|
||||
|
||||
this.ws = new WebSocket(this.url)
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempt = 0
|
||||
this.handlers.onStatus?.('connected')
|
||||
this.flushPendingQueue()
|
||||
this.startHeartbeat()
|
||||
}
|
||||
this.ws.onmessage = (evt) => {
|
||||
this.handleMessage(evt.data)
|
||||
}
|
||||
this.ws.onerror = () => {
|
||||
this.handlers.onStatus?.('error', 'WebSocket error')
|
||||
}
|
||||
this.ws.onclose = () => {
|
||||
this.stopHeartbeat()
|
||||
this.handlers.onStatus?.('disconnected')
|
||||
this.ws = null
|
||||
if (!this.closedByUser) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.closedByUser = true
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer !== null) {
|
||||
window.clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
send(payload: Record<string, unknown>): void {
|
||||
if (!this.ensureProjectScope(payload)) {
|
||||
return
|
||||
}
|
||||
const data = JSON.stringify(payload)
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
if (this.pendingQueue.length < PENDING_QUEUE_MAX) {
|
||||
this.pendingQueue.push(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.ws.send(data)
|
||||
}
|
||||
|
||||
// ── Agent management ───────────────────────────────────────────────────
|
||||
|
||||
createAgent(role: Record<string, unknown>): void {
|
||||
this.send({ type: 'create_agent', role })
|
||||
}
|
||||
|
||||
deleteAgent(agentId: string): void {
|
||||
this.send({ type: 'delete_agent', agent_id: agentId })
|
||||
}
|
||||
|
||||
moveAgent(agentId: string, officeId: string, seatZone?: string): void {
|
||||
this.send({ type: 'move_agent', agent_id: agentId, office_id: officeId, seat_zone: seatZone })
|
||||
}
|
||||
|
||||
listAgents(): void {
|
||||
this.send({ type: 'list_agents' })
|
||||
}
|
||||
|
||||
createFromTemplate(templateId: string, name?: string): void {
|
||||
this.send({ type: 'create_agent', role: { id: templateId, template: templateId, name: name ?? templateId } })
|
||||
}
|
||||
|
||||
// ── Execution mode ─────────────────────────────────────────────────────
|
||||
|
||||
setExecutionMode(mode: string, profile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
|
||||
this.send({ type: 'set_execution_mode', mode, profile: profile ?? 'corporate', preferred_agent: preferredAgent, org_id: orgId })
|
||||
}
|
||||
|
||||
// ── Kanban integration ─────────────────────────────────────────────────
|
||||
|
||||
assignTaskToAgent(projectId: string, taskId: string, agentId: string, taskTitle: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'kanban_assign')
|
||||
this.send({ type: 'kanban_assign', task_id: taskId, agent_id: agentId, task_title: taskTitle, project_id: pid })
|
||||
}
|
||||
|
||||
updateTaskStatus(projectId: string, taskId: string, status: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'kanban_status')
|
||||
this.send({ type: 'kanban_status', task_id: taskId, status, project_id: pid })
|
||||
}
|
||||
|
||||
kanbanCreateTask(opts: {
|
||||
board_id: string; column_id: string; title: string;
|
||||
description?: string; priority?: string;
|
||||
assignee_ids?: string[]; tags?: string[];
|
||||
task_id?: string;
|
||||
project_id: string;
|
||||
}): void {
|
||||
const pid = this.requireProjectId(opts.project_id, 'kanban_create_task')
|
||||
this.send({ type: 'kanban_create_task', ...opts, project_id: pid })
|
||||
}
|
||||
|
||||
kanbanUpdateTask(projectId: string, taskId: string, updates: Record<string, unknown>): void {
|
||||
const pid = this.requireProjectId(projectId, 'kanban_update_task')
|
||||
this.send({ type: 'kanban_update_task', task_id: taskId, updates, project_id: pid })
|
||||
}
|
||||
|
||||
kanbanMoveTask(projectId: string, taskId: string, columnId: string, sortOrder = 0): void {
|
||||
const pid = this.requireProjectId(projectId, 'kanban_move_task')
|
||||
this.send({ type: 'kanban_move_task', task_id: taskId, column_id: columnId, sort_order: sortOrder, project_id: pid })
|
||||
}
|
||||
|
||||
kanbanDeleteBoard(projectId: string, boardId: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'kanban_delete_board')
|
||||
this.send({ type: 'kanban_delete_board', project_id: pid, board_id: boardId })
|
||||
}
|
||||
|
||||
kanbanDeleteTask(projectId: string, taskId: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'kanban_delete_task')
|
||||
this.send({ type: 'kanban_delete_task', task_id: taskId, project_id: pid })
|
||||
}
|
||||
|
||||
kanbanSwitchView(projectId: string, level: 'global' | 'office' | 'agent', targetId?: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'kanban_switch_view')
|
||||
this.send({ type: 'kanban_switch_view', level, target_id: targetId, project_id: pid })
|
||||
}
|
||||
|
||||
getAgentDetail(agentId: string): void {
|
||||
this.send({ type: 'get_agent_detail', agent_id: agentId })
|
||||
}
|
||||
|
||||
// ── Collaboration protocol ─────────────────────────────────────────────
|
||||
|
||||
collabSync(projectId: string, switchSeq?: string, viewGeneration?: number): void {
|
||||
const pid = this.requireProjectId(projectId, 'collab_sync')
|
||||
this.send({ type: 'collab_sync', project_id: pid, switch_seq: switchSeq, view_generation: viewGeneration })
|
||||
}
|
||||
|
||||
projectIndex(projectId: string, switchSeq?: string, viewGeneration?: number): void {
|
||||
const pid = this.requireProjectId(projectId, 'project_index')
|
||||
this.send({ type: 'project_index', project_id: pid, switch_seq: switchSeq, view_generation: viewGeneration })
|
||||
}
|
||||
|
||||
// ── Session protocol ───────────────────────────────────────────────────
|
||||
|
||||
createSession(projectId: string, title?: string, execMode?: string, companyProfile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'create_session')
|
||||
this.send({
|
||||
type: 'create_session',
|
||||
project_id: pid,
|
||||
title: title ?? 'New Chat',
|
||||
exec_mode: execMode,
|
||||
company_profile: companyProfile,
|
||||
preferred_agent: preferredAgent,
|
||||
org_id: orgId,
|
||||
})
|
||||
}
|
||||
|
||||
sessionSend(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
content: string,
|
||||
attachments?: OutgoingAttachmentPayload[],
|
||||
metadata?: CheckpointReplyMetadata,
|
||||
): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_send')
|
||||
this.send({
|
||||
type: 'session_send',
|
||||
project_id: pid,
|
||||
task_id: taskId,
|
||||
content,
|
||||
attachments: attachments ?? [],
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
deleteSession(projectId: string, taskId: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_delete')
|
||||
this.send({ type: 'session_delete', project_id: pid, task_id: taskId })
|
||||
}
|
||||
|
||||
sessionUpdateTitle(projectId: string, taskId: string, title: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_update_title')
|
||||
this.send({ type: 'session_update_title', project_id: pid, task_id: taskId, title })
|
||||
}
|
||||
|
||||
sessionUpdateConfig(projectId: string, taskId: string, execMode: string, companyProfile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_update_config')
|
||||
this.send({
|
||||
type: 'session_update_config',
|
||||
project_id: pid,
|
||||
task_id: taskId,
|
||||
exec_mode: execMode,
|
||||
company_profile: companyProfile,
|
||||
preferred_agent: preferredAgent,
|
||||
org_id: orgId,
|
||||
})
|
||||
}
|
||||
|
||||
sessionStop(projectId: string, taskId: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_stop')
|
||||
this.send({ type: 'session_stop', project_id: pid, task_id: taskId })
|
||||
}
|
||||
|
||||
sessionResume(projectId: string, taskId: string, content?: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_resume')
|
||||
this.send({ type: 'session_resume', project_id: pid, task_id: taskId, content })
|
||||
}
|
||||
|
||||
sessionComplete(projectId: string, taskId: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_complete')
|
||||
this.send({ type: 'session_complete', project_id: pid, task_id: taskId })
|
||||
}
|
||||
|
||||
sessionDetail(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
opts?: { limit?: number; beforeCreatedAt?: number; beforeMessageId?: string; detailLevel?: 'summary' | 'full'; include?: string[]; viewGeneration?: number },
|
||||
): void {
|
||||
const pid = this.requireProjectId(projectId, 'session_detail')
|
||||
this.send({
|
||||
type: 'session_detail',
|
||||
project_id: pid,
|
||||
task_id: taskId,
|
||||
limit: opts?.limit,
|
||||
before_created_at: opts?.beforeCreatedAt,
|
||||
before_message_id: opts?.beforeMessageId,
|
||||
detail_level: opts?.detailLevel,
|
||||
include: opts?.include,
|
||||
view_generation: opts?.viewGeneration,
|
||||
})
|
||||
}
|
||||
|
||||
secretarySend(projectId: string, content: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'secretary_send')
|
||||
this.send({ type: 'secretary_send', project_id: pid, content })
|
||||
}
|
||||
|
||||
// ── Project management ──────────────────────────────────────────────
|
||||
|
||||
listProjects(): void {
|
||||
this.send({ type: 'list_projects' })
|
||||
}
|
||||
|
||||
createProject(projectId: string): void {
|
||||
this.send({ type: 'create_project', project_id: this.normalizeProjectId(projectId) })
|
||||
}
|
||||
|
||||
deleteProject(projectId: string): void {
|
||||
this.send({ type: 'delete_project', project_id: this.normalizeProjectId(projectId) })
|
||||
}
|
||||
|
||||
switchProject(projectId: string, switchSeq?: string): void {
|
||||
this.send({ type: 'switch_project', project_id: this.normalizeProjectId(projectId), switch_seq: switchSeq })
|
||||
}
|
||||
|
||||
// ── Org info ──────────────────────────────────────────────────────────
|
||||
|
||||
orgInfo(): void {
|
||||
this.send({ type: 'org_info' })
|
||||
}
|
||||
|
||||
// ── Phase 4: Talent Market, Employee Detail, Reorg ───────────────────
|
||||
|
||||
talentImport(repoPath: string): void {
|
||||
this.send({ type: 'talent_import', repo_path: repoPath })
|
||||
}
|
||||
|
||||
talentList(): void {
|
||||
this.send({ type: 'talent_list' })
|
||||
}
|
||||
|
||||
talentScanLocal(): void {
|
||||
this.send({ type: 'talent_scan_local' })
|
||||
}
|
||||
|
||||
talentImportSelected(templateIds: string[]): void {
|
||||
this.send({ type: 'talent_import_selected', template_ids: templateIds })
|
||||
}
|
||||
|
||||
talentHire(templateId: string, roleId: string, employeeName?: string, orgId?: string): void {
|
||||
this.send({ type: 'talent_hire', template_id: templateId, role_id: roleId, employee_name: employeeName, org_id: orgId })
|
||||
}
|
||||
|
||||
employeeDetail(employeeId: string): void {
|
||||
this.send({ type: 'employee_detail', employee_id: employeeId })
|
||||
}
|
||||
|
||||
reorgList(): void {
|
||||
this.send({ type: 'reorg_list' })
|
||||
}
|
||||
|
||||
reorgDecide(proposalId: string, approved: boolean, notes?: string): void {
|
||||
this.send({ type: 'reorg_decide', proposal_id: proposalId, approved, notes })
|
||||
}
|
||||
|
||||
importEmployeeAsAgent(employeeId: string, officeId?: string): void {
|
||||
this.send({ type: 'import_employee_as_agent', employee_id: employeeId, office_id: officeId })
|
||||
}
|
||||
|
||||
// ── OPC Market ─────────────────────────────────────────────────────────
|
||||
|
||||
marketBrowse(): void {
|
||||
this.send({ type: 'market_browse' })
|
||||
}
|
||||
|
||||
marketPreview(presetId: string): void {
|
||||
this.send({ type: 'market_preview', preset_id: presetId })
|
||||
}
|
||||
|
||||
marketApplyPreset(presetId: string, strategy: string = 'namespace'): void {
|
||||
this.send({ type: 'market_apply_preset', preset_id: presetId, strategy })
|
||||
}
|
||||
|
||||
marketListInstalled(): void {
|
||||
this.send({ type: 'market_list_installed' })
|
||||
}
|
||||
|
||||
marketExport(data: { package_id: string; name: string; description: string; version: string }): void {
|
||||
this.send({ type: 'market_export', ...data })
|
||||
}
|
||||
|
||||
marketInstall(path: string, strategy: string = 'namespace'): void {
|
||||
this.send({ type: 'market_install', path, strategy })
|
||||
}
|
||||
|
||||
marketUninstall(packageId: string): void {
|
||||
this.send({ type: 'market_uninstall', package_id: packageId })
|
||||
}
|
||||
|
||||
// ── Org Editing ───────────────────────────────────────────────────────
|
||||
|
||||
addRole(roleId: string, name: string, responsibility: string, reportsTo: string = 'owner', icon?: string | null): void {
|
||||
this.send({ type: 'add_role', role_id: roleId, name, responsibility, reports_to: reportsTo, icon: icon || null })
|
||||
}
|
||||
|
||||
bulkAddRoles(roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string; icon?: string | null }>): void {
|
||||
this.send({ type: 'bulk_add_roles', roles })
|
||||
}
|
||||
|
||||
updateRole(roleId: string, updates: {
|
||||
name?: string
|
||||
responsibility?: string
|
||||
reports_to?: string
|
||||
can_spawn?: string[]
|
||||
icon?: string | null
|
||||
execution_strategy?: string
|
||||
preferred_external_agent?: string | null
|
||||
prompt_refs?: string[]
|
||||
tools?: string[]
|
||||
}): void {
|
||||
this.send({ type: 'update_role', role_id: roleId, ...updates })
|
||||
}
|
||||
|
||||
deleteRole(roleId: string): void {
|
||||
this.send({ type: 'delete_role', role_id: roleId })
|
||||
}
|
||||
|
||||
updateRuntimePolicy(policy: Record<string, any>): void {
|
||||
this.send({ type: 'update_runtime_policy', policy })
|
||||
}
|
||||
|
||||
updateOrgStrategy(data: { final_decider_role_id?: string | null }): void {
|
||||
this.send({ type: 'update_org_strategy', ...data })
|
||||
}
|
||||
|
||||
resetArchitecture(): void {
|
||||
this.send({ type: 'reset_architecture' })
|
||||
}
|
||||
|
||||
orgConfigExport(): void {
|
||||
this.send({ type: 'org_config_export' })
|
||||
}
|
||||
|
||||
orgConfigImport(yaml: string, dryRun: boolean): void {
|
||||
this.send({ type: 'org_config_import', yaml, dry_run: dryRun })
|
||||
}
|
||||
|
||||
orgSavedList(): void {
|
||||
this.send({ type: 'org_saved_list' })
|
||||
}
|
||||
|
||||
orgSavedSaveAs(name: string, overwrite: boolean): void {
|
||||
this.send({ type: 'org_saved_save_as', name, overwrite })
|
||||
}
|
||||
|
||||
orgSavedCreate(organizationName: string, members: OrgCreateMemberInput[]): void {
|
||||
this.send({ type: 'org_saved_create', organization_name: organizationName, members })
|
||||
}
|
||||
|
||||
orgSavedLoad(name: string): void {
|
||||
this.send({ type: 'org_saved_load', name })
|
||||
}
|
||||
|
||||
orgSavedDelete(name: string): void {
|
||||
this.send({ type: 'org_saved_delete', name })
|
||||
}
|
||||
|
||||
recoveryAction(projectId: string, action: 'resume' | 'cancel' | 'scan', parentTaskId?: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'recovery_action')
|
||||
this.send({ type: 'recovery_action', project_id: pid, action, parent_task_id: parentTaskId })
|
||||
}
|
||||
|
||||
commsState(projectId: string, opts?: { task_id?: string; session_id?: string }): void {
|
||||
const pid = this.requireProjectId(projectId, 'comms_state')
|
||||
this.send({ type: 'comms_state', project_id: pid, ...(opts || {}) })
|
||||
}
|
||||
|
||||
commsReadMessage(projectId: string, path: string): void {
|
||||
const pid = this.requireProjectId(projectId, 'comms_read_message')
|
||||
this.send({ type: 'comms_read_message', project_id: pid, path })
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────
|
||||
|
||||
private normalizeProjectId(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
private requireProjectId(projectId: unknown, action: string): string {
|
||||
const pid = this.normalizeProjectId(projectId)
|
||||
if (!pid) {
|
||||
throw new Error(`${action} requires non-empty project_id`)
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
private ensureProjectScope(payload: Record<string, unknown>): boolean {
|
||||
const messageType = typeof payload.type === 'string' ? payload.type : ''
|
||||
if (!PROJECT_SCOPED_MESSAGE_TYPES.has(messageType)) {
|
||||
return true
|
||||
}
|
||||
const pid = this.normalizeProjectId(payload.project_id ?? payload.projectId)
|
||||
if (!pid) {
|
||||
const error = `${messageType} requires non-empty project_id`
|
||||
console.error(`[wsClient] ${error}`, payload)
|
||||
this.handlers.onAck?.({ ok: false, error, action: messageType })
|
||||
return false
|
||||
}
|
||||
payload.project_id = pid
|
||||
return true
|
||||
}
|
||||
|
||||
private handleMessage(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
return
|
||||
}
|
||||
let parsed: SocketEnvelope | null = null
|
||||
try {
|
||||
parsed = JSON.parse(raw) as SocketEnvelope
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
|
||||
return
|
||||
}
|
||||
try { switch (parsed.type) {
|
||||
case 'snapshot':
|
||||
this.handlers.onSnapshot?.(parsed.payload)
|
||||
break
|
||||
case 'event':
|
||||
this.handlers.onEvent?.(parsed.payload)
|
||||
break
|
||||
case 'ack':
|
||||
this.handlers.onAck?.(parsed.payload)
|
||||
break
|
||||
case 'channel_created':
|
||||
this.handlers.onChannelCreated?.(parsed.payload)
|
||||
break
|
||||
case 'board_task_created':
|
||||
case 'board_task_moved':
|
||||
this.handlers.onBoardEvent?.(parsed.payload as unknown as Record<string, unknown>)
|
||||
break
|
||||
case 'board_task_status_changed':
|
||||
case 'execution_mode_resolved':
|
||||
case 'project_run_updated':
|
||||
case 'seat_digest_updated':
|
||||
case 'work_item_batch_updated':
|
||||
case 'session_runtime_control':
|
||||
this.handlers.onCollabMessage?.(parsed.type, parsed.payload as Record<string, unknown>)
|
||||
break
|
||||
case 'cross_office_collab':
|
||||
this.handlers.onCrossOfficeCollab?.(parsed.payload)
|
||||
break
|
||||
case 'chat_new_message':
|
||||
case 'chat_channel_created':
|
||||
case 'kanban_updated':
|
||||
case 'kanban_board_created':
|
||||
case 'collab_sync_push':
|
||||
case 'project_index_push':
|
||||
this.handlers.onCollabMessage?.(parsed.type, parsed.payload as Record<string, unknown>)
|
||||
break
|
||||
case 'agent_runtime_update':
|
||||
this.handlers.onAgentRuntimeUpdate?.(parsed.payload)
|
||||
break
|
||||
case 'worker_notification':
|
||||
this.handlers.onWorkerNotification?.(parsed.payload as WorkerNotificationPayload)
|
||||
break
|
||||
case 'session_progress':
|
||||
this.handlers.onSessionProgress?.(parsed.payload)
|
||||
break
|
||||
case 'work_item_progress':
|
||||
this.handlers.onWorkItemProgress?.(parsed.payload as unknown as WorkItemProgressPayload)
|
||||
break
|
||||
case 'kanban_view_data':
|
||||
this.handlers.onKanbanViewData?.(parsed.payload)
|
||||
break
|
||||
case 'session_created':
|
||||
this.handlers.onSessionCreated?.(parsed.payload)
|
||||
break
|
||||
case 'session_updated':
|
||||
this.handlers.onSessionUpdated?.(parsed.payload)
|
||||
break
|
||||
case 'session_message':
|
||||
this.handlers.onSessionMessage?.(parsed.payload as Record<string, unknown>)
|
||||
break
|
||||
case 'session_title_updated':
|
||||
this.handlers.onSessionTitleUpdated?.(parsed.payload)
|
||||
break
|
||||
case 'session_deleted':
|
||||
this.handlers.onSessionDeleted?.(parsed.payload)
|
||||
break
|
||||
case 'child_session_created':
|
||||
this.handlers.onChildSessionCreated?.(parsed.payload)
|
||||
break
|
||||
case 'project_switched':
|
||||
this.handlers.onProjectSwitched?.(parsed.payload)
|
||||
break
|
||||
case 'project_deleted':
|
||||
this.handlers.onProjectDeleted?.(parsed.payload)
|
||||
break
|
||||
case 'org_info':
|
||||
this.handlers.onOrgInfo?.(parsed.payload)
|
||||
break
|
||||
case 'comms_state':
|
||||
this.handlers.onCommsState?.(parsed.payload as unknown as CommsStatePayload)
|
||||
break
|
||||
case 'comms_message':
|
||||
this.handlers.onCommsMessage?.(parsed.payload as unknown as CommsMessagePayload)
|
||||
break
|
||||
case 'comms_state_dirty':
|
||||
// Server pushed a "something changed" hint after a comms message
|
||||
// was sent. Re-issue the snapshot request so the panel updates
|
||||
// immediately instead of waiting for its polling tick.
|
||||
try {
|
||||
const projectId = typeof parsed.payload?.project_id === 'string' ? parsed.payload.project_id : ''
|
||||
if (projectId) this.commsState(projectId)
|
||||
} catch { /* ignore */ }
|
||||
break
|
||||
case 'recovery_status':
|
||||
this.handlers.onRecoveryStatus?.(parsed.payload)
|
||||
break
|
||||
case 'recovery_result':
|
||||
this.handlers.onRecoveryResult?.(parsed.payload)
|
||||
break
|
||||
case 'talent_list':
|
||||
this.handlers.onTalentList?.(parsed.payload)
|
||||
break
|
||||
case 'talent_scan_local':
|
||||
this.handlers.onTalentScanLocal?.(parsed.payload)
|
||||
break
|
||||
case 'employee_detail':
|
||||
this.handlers.onEmployeeDetail?.(parsed.payload)
|
||||
break
|
||||
case 'reorg_list':
|
||||
this.handlers.onReorgList?.(parsed.payload)
|
||||
break
|
||||
case 'market_list_installed':
|
||||
this.handlers.onMarketListInstalled?.(parsed.payload as unknown as { packages: Array<Record<string, unknown>> })
|
||||
break
|
||||
case 'market_browse':
|
||||
this.handlers.onMarketBrowse?.(parsed.payload as unknown as { presets: Array<Record<string, unknown>> })
|
||||
break
|
||||
case 'market_preview':
|
||||
this.handlers.onMarketPreview?.(parsed.payload as Record<string, unknown>)
|
||||
break
|
||||
case 'org_config_export':
|
||||
this.handlers.onOrgConfigExport?.(parsed.payload as { yaml: string })
|
||||
break
|
||||
case 'org_config_import':
|
||||
this.handlers.onOrgConfigImport?.(parsed.payload as any)
|
||||
break
|
||||
case 'org_saved_list':
|
||||
this.handlers.onOrgSavedList?.(parsed.payload as { orgs: SavedOrgSummary[]; active_name?: string | null })
|
||||
break
|
||||
case 'org_saved_save_as':
|
||||
this.handlers.onOrgSavedSaveAs?.(parsed.payload as { ok: boolean; name: string; error?: string })
|
||||
break
|
||||
case 'org_saved_create':
|
||||
this.handlers.onOrgSavedCreate?.(parsed.payload as OrgSavedCreatePayload)
|
||||
break
|
||||
case 'org_saved_load':
|
||||
this.handlers.onOrgSavedLoad?.(parsed.payload as { ok: boolean; name: string; error?: string })
|
||||
break
|
||||
case 'org_saved_delete':
|
||||
this.handlers.onOrgSavedDelete?.(parsed.payload as { ok: boolean; name: string; error?: string })
|
||||
break
|
||||
case 'pong':
|
||||
this.handlePong()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch (e) { console.error('[wsClient] Error handling message:', parsed.type, e) }
|
||||
}
|
||||
|
||||
private flushPendingQueue(): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
||||
const queued = this.pendingQueue.splice(0)
|
||||
for (const data of queued) {
|
||||
this.ws.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }))
|
||||
this.pongTimer = window.setTimeout(() => {
|
||||
this.pongTimer = null
|
||||
this.ws?.close()
|
||||
}, HEARTBEAT_TIMEOUT_MS)
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer !== null) {
|
||||
window.clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
if (this.pongTimer !== null) {
|
||||
window.clearTimeout(this.pongTimer)
|
||||
this.pongTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private handlePong(): void {
|
||||
if (this.pongTimer !== null) {
|
||||
window.clearTimeout(this.pongTimer)
|
||||
this.pongTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer !== null) return
|
||||
if (this.reconnectAttempt >= RECONNECT_MAX_ATTEMPTS) {
|
||||
this.handlers.onStatus?.('error', 'max reconnect attempts reached')
|
||||
return
|
||||
}
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt),
|
||||
RECONNECT_MAX_MS,
|
||||
)
|
||||
this.reconnectAttempt++
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user