Files
nexus/frontend/src/stores/live-sync.ts
T
devops df94ed3cd4
CI - Build & Test / Backend (.NET) (push) Successful in 1m19s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
feat: board-first orchestration with Gateway Bridge, live-update, and flow-board
- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend
- TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos
- FlowBoard.vue: Board-first orchestration dashboard panel
- live-sync.ts store + live.ts service: SSE-basierte Live-Updates
- Nullability-Warnung in HealthController.cs gefixt
- nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte
- .gitignore: pnpm/corepack local caches ausgeschlossen
- docs: architecture-board-first-orchestration.md hinzugefügt
- README: Backend Bridge API dokumentiert
2026-06-22 19:56:45 +02:00

173 lines
6.2 KiB
TypeScript

import { defineStore } from 'pinia'
import { openDashboardLiveStream } from '../services/live'
import type { BoardGroup, DashboardTaskDto } from './tasks'
import type { NotificationItem } from './notifications'
import type { TaskItem } from '../components/dashboard/v2/types'
import { useTaskStore } from './tasks'
import { useNotificationStore } from './notifications'
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
interface NotificationSnapshotDto {
notifications: NotificationItem[]
unreadCount: number
forUser: string
}
interface DashboardLiveSnapshotDto {
board: BoardGroup
notifications: NotificationSnapshotDto
cursor: LiveCursorDto
}
function isBoardGroup(value: unknown): value is BoardGroup {
const v = value as BoardGroup
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
}
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
}
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
detail: t.detail,
source: t.source,
}
}
export const useLiveSyncStore = defineStore('liveSync', {
state: () => ({
connected: false,
connecting: false,
lastEventAt: null as string | null,
lastHeartbeatAt: null as string | null,
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
}),
getters: {
liveIndicatorLabel: (state) => {
if (state.connecting) return 'Verbinde…'
if (state.connected) return `Live · #${state.lastSequence}`
return state.mode === 'polling' ? 'Polling' : 'Offline'
},
connectionHealth: (state) => {
if (state.connected) return 'healthy'
if (state.connecting) return 'connecting'
return 'degraded'
},
},
actions: {
async connect(forUser = 'bao') {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.controller = new AbortController()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
try {
const stream = await openDashboardLiveStream((event, data) => {
this.lastEventAt = new Date().toISOString()
if (event === 'heartbeat') {
const cursor = data as LiveCursorDto
this.lastHeartbeatAt = cursor.timestamp
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
return
}
if (event === 'snapshot') {
const snapshot = data as DashboardLiveSnapshotDto
taskStore.board = snapshot.board
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
notificationStore.notifications = snapshot.notifications.notifications
notificationStore.unreadCount = snapshot.notifications.unreadCount
this.lastSequence = snapshot.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
return
}
const eventDto = data as DashboardLiveEventDto
this.applyEnvelope(eventDto.envelope, forUser)
this.lastSequence = eventDto.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
}
},
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
taskStore.board = envelope.payload
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
return
}
if (envelope.type === 'notifications.snapshot') {
const snapshot = envelope.payload as NotificationSnapshotDto
if (snapshot.forUser !== forUser) return
notificationStore.notifications = snapshot.notifications
notificationStore.unreadCount = snapshot.unreadCount
}
},
disconnect() {
this.controller?.abort()
this.controller = null
this.connected = false
this.connecting = false
this.mode = 'polling'
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
},
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(forUser)
}, delay)
},
},
})