63 lines
3.4 KiB
TypeScript
63 lines
3.4 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll'
|
|
import { GENERATION_RECOVERY_KEY, readRecoveryMarkerCanCancel } from './use-generation'
|
|
|
|
export interface GlobalGenerationLock {
|
|
// Fail-closed Generate disable: a recovery marker, a running poll, or an unconfirmed
|
|
// poll error all mean the single global slot must be treated as busy.
|
|
isRunning: boolean
|
|
// Same poll as isRunning, gated on the backend's frozen local-GPU vs API slot.
|
|
// Never derived from live Settings — switching to local mid-API-job must not reveal Stop.
|
|
canCancel: boolean
|
|
// Slot still busy after Stop (`status=running`, `phase=cancelled`) until generate() unwinds.
|
|
isCancelling: boolean
|
|
}
|
|
|
|
// Only one generation can run at a time across the whole app (single global backend slot), but
|
|
// each project's GenSpace only tracks its OWN local isGenerating-style state — it has no idea a
|
|
// different project (or the same one, reconnected via a stale click) is already occupying that
|
|
// slot. Without this, Generate stays clickable in project B while project A is mid-generation;
|
|
// the request 409s, but only after writeRecoveryContext already overwrote A's in-flight recovery
|
|
// marker with B's (now-failed) one. Polling here lets Generate disable proactively instead.
|
|
// Stop uses the same sources: the recovery marker (immediate, frozen at job start) and
|
|
// GET /generation/progress.cancellable (authoritative once a poll lands). Hook-local canCancel
|
|
// dies on UI refresh; these do not. Live Settings must not flip Stop mid-job.
|
|
// No marker anywhere means nothing CAN be running (see subscribeWhileGenerationMayBeActive), so
|
|
// idle starts unlocked and costs no network call; once a marker exists and we're actually
|
|
// polling, an unconfirmed/failed poll is treated as locked rather than silently trusting "not
|
|
// running" — that unconfirmed-failure gap is exactly what previously let Generate stay clickable
|
|
// during another project's generation. The initial state has to check the marker too, not just
|
|
// hardcode false: a page refresh resets this hook's React state from scratch while another
|
|
// project's marker (and its still-running backend generation) survives in localStorage, and the
|
|
// first poll takes a network round trip to resolve — that gap is otherwise the same unconfirmed
|
|
// window all over again, just re-opened on every reload instead of only at first app launch.
|
|
export function useGlobalGenerationLock(): GlobalGenerationLock {
|
|
const [lock, setLock] = useState<GlobalGenerationLock>(() => ({
|
|
isRunning: localStorage.getItem(GENERATION_RECOVERY_KEY) != null,
|
|
canCancel: readRecoveryMarkerCanCancel(),
|
|
isCancelling: false,
|
|
}))
|
|
|
|
useEffect(() => subscribeWhileGenerationMayBeActive(result => {
|
|
if (!result.ok) {
|
|
// Same fail-closed Generate lock as before. Stop keeps the frozen marker bit rather
|
|
// than guessing — a starved poll during local GPU work must not hide Stop, and a
|
|
// failed poll during an API job must not reveal it.
|
|
setLock({
|
|
isRunning: true,
|
|
canCancel: readRecoveryMarkerCanCancel(),
|
|
isCancelling: false,
|
|
})
|
|
return
|
|
}
|
|
const isRunning = result.data.status === 'running'
|
|
setLock({
|
|
isRunning,
|
|
canCancel: isRunning && result.data.cancellable,
|
|
isCancelling: isRunning && result.data.phase === 'cancelled',
|
|
})
|
|
}), [])
|
|
|
|
return lock
|
|
}
|