Load/render workspace alignment (#182)
* docs: plan load render workspace alignment * fix: normalize workspace status hydration * fix: avoid duplicate backlog hydration load * refactor: use sprint store active story * refactor: migrate solo to workspace store * chore: stabilize verification ignores
This commit is contained in:
parent
98ee05d458
commit
3b5cee823c
28 changed files with 1845 additions and 577 deletions
|
|
@ -1,283 +1,8 @@
|
|||
import { create } from 'zustand'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||
|
||||
type TaskStatus = SoloTask['status']
|
||||
|
||||
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent'
|
||||
|
||||
export interface JobState {
|
||||
job_id: string
|
||||
task_id: string
|
||||
status: ClaudeJobStatusApi
|
||||
branch?: string
|
||||
pushed_at?: string | null
|
||||
pr_url?: string | null
|
||||
verify_result?: VerifyResultApi | null
|
||||
summary?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ClaudeJobEvent =
|
||||
| { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' }
|
||||
| { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; pushed_at?: string; pr_url?: string; verify_result?: VerifyResultApi; summary?: string; error?: string }
|
||||
|
||||
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801
|
||||
// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit
|
||||
// /api/realtime/solo (ST-802).
|
||||
export interface RealtimeEvent {
|
||||
op: 'I' | 'U' | 'D'
|
||||
entity: 'task' | 'story'
|
||||
id: string
|
||||
story_id?: string
|
||||
product_id: string
|
||||
sprint_id: string | null
|
||||
assignee_id: string | null
|
||||
// Task-specifieke velden (alleen aanwezig als entity === 'task')
|
||||
task_status?: TaskStatus
|
||||
task_sort_order?: number
|
||||
task_title?: string
|
||||
// Story-specifieke velden (alleen aanwezig als entity === 'story')
|
||||
story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE'
|
||||
story_sort_order?: number
|
||||
story_title?: string
|
||||
story_code?: string | null
|
||||
// Op UPDATE: lijst van kolommen die zijn veranderd
|
||||
changed_fields?: string[]
|
||||
}
|
||||
|
||||
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
||||
|
||||
interface SoloStore {
|
||||
tasks: Record<string, SoloTask>
|
||||
/** Task-ids die op dit moment een eigen optimistic write in de lucht hebben.
|
||||
* Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet
|
||||
* twee keer toegepast wordt of door een latere echo overschreven. */
|
||||
pendingOps: Set<string>
|
||||
|
||||
/** Realtime-connection state, beheerd door useSoloRealtime in de
|
||||
* (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en
|
||||
* de hook niet direct in dezelfde subtree draait. */
|
||||
realtimeStatus: RealtimeStatus
|
||||
showConnectingIndicator: boolean
|
||||
|
||||
claudeJobsByTaskId: Record<string, JobState>
|
||||
connectedWorkers: number
|
||||
|
||||
// M13: laatste quota-rapport van een actieve worker. null = geen
|
||||
// worker actief of nog geen heartbeat met quota ontvangen.
|
||||
workerQuotaPct: number | null
|
||||
workerQuotaCheckAt: string | null
|
||||
|
||||
initTasks: (tasks: SoloTask[]) => void
|
||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||
updatePlan: (taskId: string, plan: string | null) => void
|
||||
updateVerifyOnly: (taskId: string, value: boolean) => void
|
||||
updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void
|
||||
|
||||
markPending: (taskId: string) => void
|
||||
clearPending: (taskId: string) => void
|
||||
|
||||
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
|
||||
|
||||
initJobs: (jobs: JobState[]) => void
|
||||
handleJobEvent: (event: ClaudeJobEvent) => void
|
||||
|
||||
setWorkers: (count: number) => void
|
||||
incrementWorkers: () => void
|
||||
decrementWorkers: () => void
|
||||
setWorkerQuota: (pct: number, checkAt: string) => void
|
||||
|
||||
handleRealtimeEvent: (event: RealtimeEvent) => void
|
||||
}
|
||||
|
||||
export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||
tasks: {},
|
||||
pendingOps: new Set<string>(),
|
||||
realtimeStatus: 'connecting',
|
||||
showConnectingIndicator: false,
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
workerQuotaPct: null,
|
||||
workerQuotaCheckAt: null,
|
||||
|
||||
initTasks: (tasks) =>
|
||||
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
||||
|
||||
optimisticMove: (taskId, toStatus) => {
|
||||
const prev = get().tasks[taskId]?.status ?? null
|
||||
if (!prev) return null
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: toStatus } } }))
|
||||
return prev
|
||||
},
|
||||
|
||||
rollback: (taskId, prevStatus) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: prevStatus } } })),
|
||||
|
||||
updatePlan: (taskId, plan) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
|
||||
|
||||
updateVerifyOnly: (taskId, value) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
|
||||
|
||||
updateVerifyRequired: (taskId, value) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })),
|
||||
|
||||
markPending: (taskId) =>
|
||||
set((s) => {
|
||||
if (s.pendingOps.has(taskId)) return s
|
||||
const next = new Set(s.pendingOps)
|
||||
next.add(taskId)
|
||||
return { pendingOps: next }
|
||||
}),
|
||||
|
||||
clearPending: (taskId) =>
|
||||
set((s) => {
|
||||
if (!s.pendingOps.has(taskId)) return s
|
||||
const next = new Set(s.pendingOps)
|
||||
next.delete(taskId)
|
||||
return { pendingOps: next }
|
||||
}),
|
||||
|
||||
setRealtimeStatus: (status, showConnectingIndicator) =>
|
||||
set((s) => {
|
||||
if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) {
|
||||
return s
|
||||
}
|
||||
return { realtimeStatus: status, showConnectingIndicator }
|
||||
}),
|
||||
|
||||
initJobs: (jobs) =>
|
||||
set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }),
|
||||
|
||||
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
|
||||
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
|
||||
decrementWorkers: () =>
|
||||
set((s) => ({
|
||||
connectedWorkers: Math.max(0, s.connectedWorkers - 1),
|
||||
// Reset quota-state als alle workers weg zijn — pct van een vertrokken
|
||||
// worker is niet meer actueel.
|
||||
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
|
||||
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
|
||||
})),
|
||||
setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
|
||||
|
||||
handleJobEvent: (event) => {
|
||||
const { job_id, task_id } = event
|
||||
if (event.type === 'claude_job_enqueued') {
|
||||
set((s) => ({
|
||||
claudeJobsByTaskId: {
|
||||
...s.claudeJobsByTaskId,
|
||||
[task_id]: { job_id, task_id, status: 'queued' },
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (event.type === 'claude_job_status') {
|
||||
const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event
|
||||
if (status === 'cancelled') {
|
||||
set((s) => {
|
||||
const next = { ...s.claudeJobsByTaskId }
|
||||
delete next[task_id]
|
||||
return { claudeJobsByTaskId: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
set((s) => ({
|
||||
claudeJobsByTaskId: {
|
||||
...s.claudeJobsByTaskId,
|
||||
[task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error },
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
handleRealtimeEvent: (event) => {
|
||||
if (event.entity === 'task') {
|
||||
const { id, op } = event
|
||||
|
||||
if (op === 'D') {
|
||||
set((s) => {
|
||||
if (!(id in s.tasks)) return s
|
||||
const next = { ...s.tasks }
|
||||
delete next[id]
|
||||
return { tasks: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken
|
||||
// zonder story-context (story_title, story_code) renderen we niet
|
||||
// — gebruiker ziet ze pas na een refresh. Acceptabel voor v1.
|
||||
const existing = get().tasks[id]
|
||||
if (!existing) return
|
||||
|
||||
if (get().pendingOps.has(id)) {
|
||||
// Echo van een eigen optimistic move — laat de optimistic-state staan
|
||||
return
|
||||
}
|
||||
|
||||
const updates: Partial<SoloTask> = {}
|
||||
if (event.task_status !== undefined && event.task_status !== existing.status) {
|
||||
updates.status = event.task_status
|
||||
}
|
||||
if (
|
||||
event.task_sort_order !== undefined &&
|
||||
event.task_sort_order !== existing.sort_order
|
||||
) {
|
||||
updates.sort_order = event.task_sort_order
|
||||
}
|
||||
if (event.task_title !== undefined && event.task_title !== existing.title) {
|
||||
updates.title = event.task_title
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return
|
||||
set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } }))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.entity === 'story') {
|
||||
const { id, op } = event
|
||||
|
||||
if (op === 'D') {
|
||||
// Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende
|
||||
// SoloTask-records uit de store.
|
||||
set((s) => {
|
||||
const next: Record<string, SoloTask> = {}
|
||||
for (const [taskId, task] of Object.entries(s.tasks)) {
|
||||
if (task.story_id !== id) next[taskId] = task
|
||||
}
|
||||
return { tasks: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = get().tasks
|
||||
const affectedIds = Object.entries(tasks)
|
||||
.filter(([, t]) => t.story_id === id)
|
||||
.map(([taskId]) => taskId)
|
||||
|
||||
if (affectedIds.length === 0) return
|
||||
|
||||
const newTitle = event.story_title
|
||||
const newCode = event.story_code ?? null
|
||||
|
||||
set((s) => {
|
||||
const next = { ...s.tasks }
|
||||
for (const taskId of affectedIds) {
|
||||
const t = next[taskId]
|
||||
const titleChanged = newTitle !== undefined && t.story_title !== newTitle
|
||||
const codeChanged = newCode !== t.story_code
|
||||
if (!titleChanged && !codeChanged) continue
|
||||
next[taskId] = {
|
||||
...t,
|
||||
...(titleChanged && newTitle !== undefined && { story_title: newTitle }),
|
||||
...(codeChanged && { story_code: newCode }),
|
||||
}
|
||||
}
|
||||
return { tasks: next }
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
export { useSoloWorkspaceStore as useSoloStore } from '@/stores/solo-workspace/store'
|
||||
export type {
|
||||
ClaudeJobEvent,
|
||||
JobState,
|
||||
RealtimeEvent,
|
||||
RealtimeStatus,
|
||||
VerifyResultApi,
|
||||
} from '@/stores/solo-workspace/types'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue