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
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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)
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user