* 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
619 lines
18 KiB
TypeScript
619 lines
18 KiB
TypeScript
import { create } from 'zustand'
|
|
import type {
|
|
ClaudeJobEvent,
|
|
JobState,
|
|
RealtimeEvent,
|
|
RealtimeStatus,
|
|
ResyncReason,
|
|
SoloColumnStatus,
|
|
SoloTask,
|
|
SoloTaskStatus,
|
|
SoloUnassignedStory,
|
|
SoloWorkspaceProduct,
|
|
SoloWorkspaceSnapshot,
|
|
SoloWorkspaceSprint,
|
|
SoloVerifyRequired,
|
|
} from './types'
|
|
|
|
interface ContextSlice {
|
|
activeProduct: SoloWorkspaceProduct | null
|
|
activeSprint: SoloWorkspaceSprint | null
|
|
activeUserId: string | null
|
|
}
|
|
|
|
interface EntitiesSlice {
|
|
tasksById: Record<string, SoloTask>
|
|
unassignedStoriesById: Record<string, SoloUnassignedStory>
|
|
jobsByTaskId: Record<string, JobState>
|
|
}
|
|
|
|
interface RelationsSlice {
|
|
taskIdsByColumn: Record<SoloColumnStatus, string[]>
|
|
unassignedStoryIds: string[]
|
|
}
|
|
|
|
interface LoadingSlice {
|
|
loadedProductId: string | null
|
|
loadedSprintId: string | null
|
|
loadingSprintId: string | null
|
|
activeRequestId: string | null
|
|
}
|
|
|
|
interface SyncSlice {
|
|
realtimeStatus: RealtimeStatus
|
|
showConnectingIndicator: boolean
|
|
lastEventAt: number | null
|
|
lastResyncAt: number | null
|
|
resyncReason: ResyncReason | null
|
|
}
|
|
|
|
interface State {
|
|
context: ContextSlice
|
|
entities: EntitiesSlice
|
|
relations: RelationsSlice
|
|
loading: LoadingSlice
|
|
sync: SyncSlice
|
|
pendingOps: Set<string>
|
|
tasks: Record<string, SoloTask>
|
|
unassignedStoriesById: Record<string, SoloUnassignedStory>
|
|
claudeJobsByTaskId: Record<string, JobState>
|
|
realtimeStatus: RealtimeStatus
|
|
showConnectingIndicator: boolean
|
|
connectedWorkers: number
|
|
workerQuotaPct: number | null
|
|
workerQuotaCheckAt: string | null
|
|
}
|
|
|
|
interface Actions {
|
|
hydrateSnapshot(snapshot: SoloWorkspaceSnapshot): void
|
|
initTasks(tasks: SoloTask[]): void
|
|
hydrateUnassignedStories(stories: SoloUnassignedStory[]): void
|
|
removeUnassignedStory(storyId: string): void
|
|
|
|
optimisticMove(taskId: string, toStatus: SoloTaskStatus): SoloTaskStatus | null
|
|
rollback(taskId: string, prevStatus: SoloTaskStatus): void
|
|
updatePlan(taskId: string, plan: string | null): void
|
|
updateVerifyOnly(taskId: string, value: boolean): void
|
|
updateVerifyRequired(taskId: string, value: SoloVerifyRequired): 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
|
|
ensureWorkspaceLoaded(productId: string, sprintId?: string, requestId?: string): Promise<void>
|
|
resyncActiveScopes(reason: ResyncReason): Promise<void>
|
|
}
|
|
|
|
export type SoloWorkspaceStore = State & Actions
|
|
|
|
const EMPTY_COLUMNS: Record<SoloColumnStatus, string[]> = {
|
|
TO_DO: [],
|
|
IN_PROGRESS: [],
|
|
DONE: [],
|
|
}
|
|
|
|
const initialState: State = {
|
|
context: {
|
|
activeProduct: null,
|
|
activeSprint: null,
|
|
activeUserId: null,
|
|
},
|
|
entities: {
|
|
tasksById: {},
|
|
unassignedStoriesById: {},
|
|
jobsByTaskId: {},
|
|
},
|
|
relations: {
|
|
taskIdsByColumn: EMPTY_COLUMNS,
|
|
unassignedStoryIds: [],
|
|
},
|
|
loading: {
|
|
loadedProductId: null,
|
|
loadedSprintId: null,
|
|
loadingSprintId: null,
|
|
activeRequestId: null,
|
|
},
|
|
sync: {
|
|
realtimeStatus: 'connecting',
|
|
showConnectingIndicator: false,
|
|
lastEventAt: null,
|
|
lastResyncAt: null,
|
|
resyncReason: null,
|
|
},
|
|
pendingOps: new Set<string>(),
|
|
tasks: {},
|
|
unassignedStoriesById: {},
|
|
claudeJobsByTaskId: {},
|
|
realtimeStatus: 'connecting',
|
|
showConnectingIndicator: false,
|
|
connectedWorkers: 0,
|
|
workerQuotaPct: null,
|
|
workerQuotaCheckAt: null,
|
|
}
|
|
|
|
function getColumnStatus(status: SoloTaskStatus): SoloColumnStatus {
|
|
if (status === 'REVIEW') return 'IN_PROGRESS'
|
|
return status
|
|
}
|
|
|
|
function compareTask(a: SoloTask, b: SoloTask): number {
|
|
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
|
|
if (a.priority !== b.priority) return a.priority - b.priority
|
|
const aCode = a.task_code ?? ''
|
|
const bCode = b.task_code ?? ''
|
|
const codeCompare = aCode.localeCompare(bCode, 'nl', { numeric: true })
|
|
if (codeCompare !== 0) return codeCompare
|
|
return a.id.localeCompare(b.id)
|
|
}
|
|
|
|
function compareUnassignedStory(a: SoloUnassignedStory, b: SoloUnassignedStory): number {
|
|
const aCode = a.code ?? ''
|
|
const bCode = b.code ?? ''
|
|
const codeCompare = aCode.localeCompare(bCode, 'nl', { numeric: true })
|
|
if (codeCompare !== 0) return codeCompare
|
|
return a.title.localeCompare(b.title, 'nl', { numeric: true })
|
|
}
|
|
|
|
function buildTaskRelations(tasksById: Record<string, SoloTask>): Record<SoloColumnStatus, string[]> {
|
|
const next: Record<SoloColumnStatus, string[]> = {
|
|
TO_DO: [],
|
|
IN_PROGRESS: [],
|
|
DONE: [],
|
|
}
|
|
const tasks = Object.values(tasksById).sort(compareTask)
|
|
for (const task of tasks) {
|
|
next[getColumnStatus(task.status)].push(task.id)
|
|
}
|
|
return next
|
|
}
|
|
|
|
function buildUnassignedRelations(storiesById: Record<string, SoloUnassignedStory>): string[] {
|
|
return Object.values(storiesById)
|
|
.sort(compareUnassignedStory)
|
|
.map((story) => story.id)
|
|
}
|
|
|
|
function normalizeTask(input: SoloTask): SoloTask {
|
|
return {
|
|
...input,
|
|
status: normalizeTaskStatus(input.status),
|
|
}
|
|
}
|
|
|
|
function normalizeTaskStatus(status: string): SoloTaskStatus {
|
|
if (status === 'IN_PROGRESS' || status === 'REVIEW' || status === 'DONE') return status
|
|
return 'TO_DO'
|
|
}
|
|
|
|
function mapTasks(tasks: SoloTask[]): Record<string, SoloTask> {
|
|
return Object.fromEntries(tasks.map((task) => [task.id, normalizeTask(task)]))
|
|
}
|
|
|
|
function mapUnassignedStories(stories: SoloUnassignedStory[]): Record<string, SoloUnassignedStory> {
|
|
return Object.fromEntries(stories.map((story) => [story.id, story]))
|
|
}
|
|
|
|
function newRequestId(): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID()
|
|
}
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
}
|
|
|
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const response = await fetch(url, { cache: 'no-store', ...init })
|
|
if (!response.ok) {
|
|
throw new Error(`Fetch ${url} failed with ${response.status}`)
|
|
}
|
|
return (await response.json()) as T
|
|
}
|
|
|
|
function taskPatchFromEvent(event: RealtimeEvent): Partial<SoloTask> {
|
|
const status = event.status ?? event.task_status
|
|
return {
|
|
...(status && { status: normalizeTaskStatus(status) }),
|
|
...((event.sort_order ?? event.task_sort_order) !== undefined && {
|
|
sort_order: event.sort_order ?? event.task_sort_order,
|
|
}),
|
|
...((event.title ?? event.task_title) !== undefined && {
|
|
title: event.title ?? event.task_title,
|
|
}),
|
|
...(event.description !== undefined && { description: event.description }),
|
|
...(event.priority !== undefined && { priority: event.priority }),
|
|
...(event.story_id !== undefined && { story_id: event.story_id }),
|
|
}
|
|
}
|
|
|
|
function storyTitleFromEvent(event: RealtimeEvent): string | undefined {
|
|
return event.title ?? event.story_title
|
|
}
|
|
|
|
function storyCodeFromEvent(event: RealtimeEvent): string | null | undefined {
|
|
return event.code ?? event.story_code
|
|
}
|
|
|
|
export const useSoloWorkspaceStore = create<SoloWorkspaceStore>((set, get) => ({
|
|
...initialState,
|
|
|
|
hydrateSnapshot(snapshot) {
|
|
const tasksById = mapTasks(snapshot.tasks)
|
|
const unassignedStoriesById = mapUnassignedStories(snapshot.unassignedStories)
|
|
set((s) => ({
|
|
context: {
|
|
activeProduct: snapshot.product,
|
|
activeSprint: snapshot.sprint,
|
|
activeUserId: snapshot.activeUserId,
|
|
},
|
|
entities: {
|
|
...s.entities,
|
|
tasksById,
|
|
unassignedStoriesById,
|
|
},
|
|
relations: {
|
|
taskIdsByColumn: buildTaskRelations(tasksById),
|
|
unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById),
|
|
},
|
|
loading: {
|
|
...s.loading,
|
|
loadedProductId: snapshot.product.id,
|
|
loadedSprintId: snapshot.sprint.id,
|
|
loadingSprintId: null,
|
|
},
|
|
tasks: tasksById,
|
|
unassignedStoriesById,
|
|
}))
|
|
},
|
|
|
|
initTasks(tasks) {
|
|
const tasksById = mapTasks(tasks)
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById },
|
|
relations: {
|
|
...s.relations,
|
|
taskIdsByColumn: buildTaskRelations(tasksById),
|
|
},
|
|
tasks: tasksById,
|
|
}))
|
|
},
|
|
|
|
hydrateUnassignedStories(stories) {
|
|
const unassignedStoriesById = mapUnassignedStories(stories)
|
|
set((s) => ({
|
|
entities: { ...s.entities, unassignedStoriesById },
|
|
relations: {
|
|
...s.relations,
|
|
unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById),
|
|
},
|
|
unassignedStoriesById,
|
|
}))
|
|
},
|
|
|
|
removeUnassignedStory(storyId) {
|
|
set((s) => {
|
|
if (!s.entities.unassignedStoriesById[storyId]) return s
|
|
const unassignedStoriesById = { ...s.entities.unassignedStoriesById }
|
|
delete unassignedStoriesById[storyId]
|
|
return {
|
|
entities: { ...s.entities, unassignedStoriesById },
|
|
relations: {
|
|
...s.relations,
|
|
unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById),
|
|
},
|
|
unassignedStoriesById,
|
|
}
|
|
})
|
|
},
|
|
|
|
optimisticMove(taskId, toStatus) {
|
|
const prev = get().tasks[taskId]?.status ?? null
|
|
if (!prev) return null
|
|
const task = { ...get().tasks[taskId], status: toStatus }
|
|
const tasksById = { ...get().tasks, [taskId]: task }
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById },
|
|
relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) },
|
|
tasks: tasksById,
|
|
}))
|
|
return prev
|
|
},
|
|
|
|
rollback(taskId, prevStatus) {
|
|
const existing = get().tasks[taskId]
|
|
if (!existing) return
|
|
const tasksById = { ...get().tasks, [taskId]: { ...existing, status: prevStatus } }
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById },
|
|
relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) },
|
|
tasks: tasksById,
|
|
}))
|
|
},
|
|
|
|
updatePlan(taskId, plan) {
|
|
const existing = get().tasks[taskId]
|
|
if (!existing) return
|
|
const tasksById = { ...get().tasks, [taskId]: { ...existing, implementation_plan: plan } }
|
|
set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById }))
|
|
},
|
|
|
|
updateVerifyOnly(taskId, value) {
|
|
const existing = get().tasks[taskId]
|
|
if (!existing) return
|
|
const tasksById = { ...get().tasks, [taskId]: { ...existing, verify_only: value } }
|
|
set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById }))
|
|
},
|
|
|
|
updateVerifyRequired(taskId, value) {
|
|
const existing = get().tasks[taskId]
|
|
if (!existing) return
|
|
const tasksById = { ...get().tasks, [taskId]: { ...existing, verify_required: value } }
|
|
set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById }))
|
|
},
|
|
|
|
markPending(taskId) {
|
|
set((s) => {
|
|
if (s.pendingOps.has(taskId)) return s
|
|
const pendingOps = new Set(s.pendingOps)
|
|
pendingOps.add(taskId)
|
|
return { pendingOps }
|
|
})
|
|
},
|
|
|
|
clearPending(taskId) {
|
|
set((s) => {
|
|
if (!s.pendingOps.has(taskId)) return s
|
|
const pendingOps = new Set(s.pendingOps)
|
|
pendingOps.delete(taskId)
|
|
return { pendingOps }
|
|
})
|
|
},
|
|
|
|
setRealtimeStatus(status, showConnectingIndicator) {
|
|
set((s) => {
|
|
if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) {
|
|
return s
|
|
}
|
|
return {
|
|
sync: { ...s.sync, realtimeStatus: status, showConnectingIndicator },
|
|
realtimeStatus: status,
|
|
showConnectingIndicator,
|
|
}
|
|
})
|
|
},
|
|
|
|
initJobs(jobs) {
|
|
const jobsByTaskId = Object.fromEntries(jobs.map((job) => [job.task_id, job]))
|
|
set((s) => ({
|
|
entities: { ...s.entities, jobsByTaskId },
|
|
claudeJobsByTaskId: jobsByTaskId,
|
|
}))
|
|
},
|
|
|
|
handleJobEvent(event) {
|
|
const { job_id, task_id } = event
|
|
if (event.type === 'claude_job_enqueued') {
|
|
set((s) => {
|
|
const jobsByTaskId = {
|
|
...s.claudeJobsByTaskId,
|
|
[task_id]: { job_id, task_id, status: 'queued' as const },
|
|
}
|
|
return {
|
|
entities: { ...s.entities, jobsByTaskId },
|
|
claudeJobsByTaskId: jobsByTaskId,
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event
|
|
if (status === 'cancelled') {
|
|
set((s) => {
|
|
const jobsByTaskId = { ...s.claudeJobsByTaskId }
|
|
delete jobsByTaskId[task_id]
|
|
return {
|
|
entities: { ...s.entities, jobsByTaskId },
|
|
claudeJobsByTaskId: jobsByTaskId,
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
set((s) => {
|
|
const jobsByTaskId = {
|
|
...s.claudeJobsByTaskId,
|
|
[task_id]: {
|
|
job_id,
|
|
task_id,
|
|
status,
|
|
branch,
|
|
pushed_at,
|
|
pr_url,
|
|
verify_result,
|
|
summary,
|
|
error,
|
|
},
|
|
}
|
|
return {
|
|
entities: { ...s.entities, jobsByTaskId },
|
|
claudeJobsByTaskId: jobsByTaskId,
|
|
}
|
|
})
|
|
},
|
|
|
|
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),
|
|
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
|
|
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
|
|
}))
|
|
},
|
|
|
|
setWorkerQuota(pct, checkAt) {
|
|
set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt })
|
|
},
|
|
|
|
handleRealtimeEvent(event) {
|
|
set((s) => ({ sync: { ...s.sync, lastEventAt: Date.now() } }))
|
|
|
|
const ctx = get().context
|
|
if (ctx.activeProduct?.id && event.product_id !== ctx.activeProduct.id) return
|
|
|
|
if (event.entity === 'task') {
|
|
if (event.op === 'D') {
|
|
const existing = get().tasks[event.id]
|
|
if (!existing) return
|
|
const tasksById = { ...get().tasks }
|
|
delete tasksById[event.id]
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById },
|
|
relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) },
|
|
tasks: tasksById,
|
|
}))
|
|
return
|
|
}
|
|
|
|
const existing = get().tasks[event.id]
|
|
if (!existing) {
|
|
if (
|
|
event.assignee_id === ctx.activeUserId &&
|
|
event.sprint_id === ctx.activeSprint?.id
|
|
) {
|
|
void get().resyncActiveScopes('unknown-event')
|
|
}
|
|
return
|
|
}
|
|
|
|
if (
|
|
event.assignee_id !== null &&
|
|
ctx.activeUserId &&
|
|
event.assignee_id !== ctx.activeUserId
|
|
) {
|
|
const tasksById = { ...get().tasks }
|
|
delete tasksById[event.id]
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById },
|
|
relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) },
|
|
tasks: tasksById,
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (get().pendingOps.has(event.id)) return
|
|
|
|
const patch = taskPatchFromEvent(event)
|
|
if (Object.keys(patch).length === 0) return
|
|
const tasksById = {
|
|
...get().tasks,
|
|
[event.id]: { ...existing, ...patch },
|
|
}
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById },
|
|
relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) },
|
|
tasks: tasksById,
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (event.op === 'D') {
|
|
const tasksById = Object.fromEntries(
|
|
Object.entries(get().tasks).filter(([, task]) => task.story_id !== event.id),
|
|
)
|
|
const unassignedStoriesById = { ...get().entities.unassignedStoriesById }
|
|
delete unassignedStoriesById[event.id]
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById, unassignedStoriesById },
|
|
relations: {
|
|
taskIdsByColumn: buildTaskRelations(tasksById),
|
|
unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById),
|
|
},
|
|
tasks: tasksById,
|
|
unassignedStoriesById,
|
|
}))
|
|
return
|
|
}
|
|
|
|
const affectedIds = Object.entries(get().tasks)
|
|
.filter(([, task]) => task.story_id === event.id)
|
|
.map(([taskId]) => taskId)
|
|
const newTitle = storyTitleFromEvent(event)
|
|
const newCode = storyCodeFromEvent(event)
|
|
|
|
if (affectedIds.length > 0 && (newTitle !== undefined || newCode !== undefined)) {
|
|
const tasksById = { ...get().tasks }
|
|
for (const taskId of affectedIds) {
|
|
const task = tasksById[taskId]
|
|
tasksById[taskId] = {
|
|
...task,
|
|
...(newTitle !== undefined && { story_title: newTitle }),
|
|
...(newCode !== undefined && { story_code: newCode }),
|
|
}
|
|
}
|
|
set((s) => ({
|
|
entities: { ...s.entities, tasksById },
|
|
relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) },
|
|
tasks: tasksById,
|
|
}))
|
|
}
|
|
|
|
if (
|
|
event.sprint_id === ctx.activeSprint?.id &&
|
|
(event.assignee_id === null || event.assignee_id === ctx.activeUserId)
|
|
) {
|
|
void get().resyncActiveScopes('unknown-event')
|
|
}
|
|
},
|
|
|
|
async ensureWorkspaceLoaded(productId, sprintId, requestId) {
|
|
const activeRequestId = requestId ?? newRequestId()
|
|
set((s) => ({
|
|
loading: {
|
|
...s.loading,
|
|
loadingSprintId: sprintId ?? s.context.activeSprint?.id ?? null,
|
|
activeRequestId,
|
|
},
|
|
}))
|
|
try {
|
|
const params = sprintId ? `?sprint_id=${encodeURIComponent(sprintId)}` : ''
|
|
const snapshot = await fetchJson<SoloWorkspaceSnapshot | null>(
|
|
`/api/products/${encodeURIComponent(productId)}/solo-workspace${params}`,
|
|
)
|
|
if (get().loading.activeRequestId !== activeRequestId) return
|
|
if (!snapshot) return
|
|
get().hydrateSnapshot(snapshot)
|
|
} finally {
|
|
set((s) => ({
|
|
loading: {
|
|
...s.loading,
|
|
loadingSprintId:
|
|
s.loading.activeRequestId === activeRequestId ? null : s.loading.loadingSprintId,
|
|
},
|
|
}))
|
|
}
|
|
},
|
|
|
|
async resyncActiveScopes(reason) {
|
|
const ctx = get().context
|
|
if (!ctx.activeProduct?.id) return
|
|
set((s) => ({
|
|
sync: { ...s.sync, lastResyncAt: Date.now(), resyncReason: reason },
|
|
}))
|
|
await get().ensureWorkspaceLoaded(ctx.activeProduct.id, ctx.activeSprint?.id)
|
|
},
|
|
}))
|