df94ed3cd4
- 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
173 lines
6.2 KiB
TypeScript
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)
|
|
},
|
|
},
|
|
})
|