Keep a generation log so wiped or failed jobs can be re-queued.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-03 16:15:09 -05:00
co-authored by Cursor
parent b5014f02b4
commit 88c5d25b78
10 changed files with 421 additions and 18 deletions
+149 -6
View File
@@ -374,6 +374,58 @@
</li>
</ol>
</section>
<section v-if="logEntries.length" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-4">
<div>
<h2 class="font-display text-xl font-semibold text-zinc-100">Recent generations</h2>
<p class="mt-1 text-sm text-zinc-400">
Kept even after the queue clears or a zombie job is wiped. Re-queue to run again, or load the prompt into Studio.
</p>
</div>
<ul class="space-y-2">
<li
v-for="entry in logEntries"
:key="entry.id"
class="flex flex-wrap items-start justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"
>
<div class="min-w-0 flex-1">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">
{{ logKindLabel(entry) }} · {{ logStatusLabel(entry.status) }}
<span class="normal-case tracking-normal text-zinc-600"> · {{ formatLogWhen(entry.updatedAt || entry.createdAt) }}</span>
</p>
<p class="mt-1 truncate text-sm font-medium text-zinc-100">{{ entry.name || 'Untitled' }}</p>
<p class="mt-0.5 line-clamp-2 text-xs text-zinc-400">{{ promptSnippet(entry.prompt) }}</p>
<p v-if="entry.lastError" class="mt-1 text-xs text-red-300">{{ entry.lastError }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100 hover:border-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="requeueLog(entry)"
>
Re-queue
</button>
<button
type="button"
class="rounded-full border border-sky-300/40 px-3 py-1.5 text-xs text-sky-100 hover:border-sky-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="loadLog(entry)"
>
Load
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="dismissLog(entry)"
>
Dismiss
</button>
</div>
</li>
</ul>
</section>
</main>
<Teleport to="body">
@@ -420,6 +472,7 @@
<script setup lang="ts">
import { insertActivationPhrase, loraIdentityKey, loraMatchesUse, loraStackLabel, persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
import { LOAD_GENERATION_PAYLOAD_KEY } from '~/utils/queuedJob'
type Segment = {
index: number
@@ -483,8 +536,22 @@ const videoLoras = ref<string[]>([])
const loraTriggers = ref<Record<string, string>>({})
const loraAliases = ref<Record<string, string>>({})
const inspectJobId = ref('')
type GenerationLogEntry = {
id: string
createdAt: number
updatedAt: number
kind: string
name: string
prompt: string
status: string
lastError?: string
payload: Record<string, any>
imagePipeline?: string
}
const jobs = ref<StudioJobRow[]>([])
const orphans = ref<Queue[]>([])
const logEntries = ref<GenerationLogEntry[]>([])
const waitingCount = ref(0)
const pendingCount = ref(0)
const queuePaused = ref(false)
@@ -717,12 +784,15 @@ function mergeQueues(incoming: Queue[]) {
}
async function loadQueues() {
const data = await $fetch<{
jobs?: StudioJobRow[]
orphans?: Queue[]
waitingCount?: number
paused?: boolean
}>('/api/studio-queue?full=1')
const [data, logData] = await Promise.all([
$fetch<{
jobs?: StudioJobRow[]
orphans?: Queue[]
waitingCount?: number
paused?: boolean
}>('/api/studio-queue?full=1'),
$fetch<{ entries?: GenerationLogEntry[] }>('/api/generation-log').catch(() => ({ entries: [] as GenerationLogEntry[] }))
])
const nextJobs = (data.jobs || []).map((job) => (
job.shots ? { ...job, shots: mergeQueues([job.shots])[0] } : job
))
@@ -730,6 +800,7 @@ async function loadQueues() {
orphans.value = mergeQueues(data.orphans || [])
waitingCount.value = data.waitingCount || 0
queuePaused.value = data.paused === true
logEntries.value = logData.entries || []
pendingCount.value = [
...nextJobs.flatMap(job => job.shots ? [job.shots] : []),
...orphans.value
@@ -821,6 +892,78 @@ function jobKindLabel(job: StudioJobRow) {
return 'VIDEO'
}
function logKindLabel(entry: GenerationLogEntry) {
if (entry.kind === 'music') return 'MUSIC'
if (entry.kind === 'edit') {
if (entry.payload?.imagePipeline === 'v2') return 'IMAGE V2'
return 'IMAGE'
}
return 'VIDEO'
}
function logStatusLabel(status: string) {
if (status === 'complete') return 'done'
if (status === 'error') return 'failed'
if (status === 'cancelled') return 'cancelled'
if (status === 'running') return 'running'
return 'queued'
}
function formatLogWhen(ts: number) {
if (!ts) return ''
try {
return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
} catch {
return ''
}
}
async function requeueLog(entry: GenerationLogEntry) {
busyId.value = entry.id
error.value = ''
try {
await $fetch(`/api/generation-log/${entry.id}/requeue`, { method: 'POST' })
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not re-queue that generation'
} finally {
busyId.value = ''
}
}
async function loadLog(entry: GenerationLogEntry) {
busyId.value = entry.id
error.value = ''
try {
sessionStorage.setItem(LOAD_GENERATION_PAYLOAD_KEY, JSON.stringify({
kind: entry.kind,
payload: entry.payload
}))
if (entry.kind === 'music') {
await navigateTo('/music')
} else {
await navigateTo('/')
}
} catch (err: any) {
error.value = err?.message || 'Could not load that generation'
} finally {
busyId.value = ''
}
}
async function dismissLog(entry: GenerationLogEntry) {
busyId.value = entry.id
error.value = ''
try {
await $fetch(`/api/generation-log/${entry.id}`, { method: 'DELETE' })
logEntries.value = logEntries.value.filter(item => item.id !== entry.id)
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not dismiss that entry'
} finally {
busyId.value = ''
}
}
function jobThumb(job: StudioJobRow) {
if (!job.stillId || job.hideThumbnail || job.folderLocked) return ''
return `/api/library/stills/${job.stillId}`