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
+8
View File
@@ -0,0 +1,8 @@
import { deleteGenerationLogEntry } from '~/server/utils/generationLog'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
await deleteGenerationLogEntry(owner, id)
return { ok: true }
})
+7
View File
@@ -0,0 +1,7 @@
import { getGenerationLogEntry } from '~/server/utils/generationLog'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
return getGenerationLogEntry(owner, id)
})
@@ -0,0 +1,7 @@
import { requeueGenerationLog } from '~/server/utils/generationLog'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
return await requeueGenerationLog(owner, id)
})
+9
View File
@@ -0,0 +1,9 @@
import { listGenerationLog } from '~/server/utils/generationLog'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const limit = Number(getQuery(event).limit || 40)
return {
entries: listGenerationLog(owner, Number.isFinite(limit) ? limit : 40)
}
})
+159
View File
@@ -0,0 +1,159 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { ownerRoot } from '~/server/utils/library'
import type { StudioJobKind, StudioJobPayload } from '~/server/utils/studioQueue'
export type GenerationLogStatus = 'queued' | 'running' | 'complete' | 'error' | 'cancelled'
export type GenerationLogEntry = {
id: string
studioJobId?: string
createdAt: number
updatedAt: number
kind: StudioJobKind
name: string
prompt: string
status: GenerationLogStatus
lastError?: string
payload: StudioJobPayload
}
const MAX_ENTRIES = 80
const writeChains = new Map<string, Promise<unknown>>()
function logPath(owner: string) {
return join(ownerRoot(owner), 'generation-log.json')
}
function ensureOwner(owner: string) {
mkdirSync(ownerRoot(owner), { recursive: true })
}
function readLog(owner: string): GenerationLogEntry[] {
ensureOwner(owner)
const path = logPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object' && item.id && item.payload) : []
} catch {
return []
}
}
function writeLog(owner: string, entries: GenerationLogEntry[]) {
ensureOwner(owner)
const path = logPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(entries, null, 2))
renameSync(tmp, path)
}
function mutate<T>(owner: string, fn: (entries: GenerationLogEntry[]) => T): Promise<T> {
const prev = writeChains.get(owner) || Promise.resolve()
const run = prev.then(() => {
const entries = readLog(owner)
const result = fn(entries)
writeLog(owner, entries)
return result
})
writeChains.set(owner, run.then(() => undefined, () => undefined))
return run
}
function snippetName(payload: StudioJobPayload, kind: StudioJobKind) {
const named = String(payload.name || '').trim()
if (named) return named.slice(0, 80)
if (kind === 'music') return String(payload.prompt || 'Track').slice(0, 80)
return String(payload.prompt || 'Untitled').slice(0, 80)
}
export function listGenerationLog(owner: string, limit = 40) {
return readLog(owner).slice(0, Math.max(1, Math.min(MAX_ENTRIES, limit)))
}
export function getGenerationLogEntry(owner: string, id: string) {
const entry = readLog(owner).find(item => item.id === id)
if (!entry) throw createError({ statusCode: 404, statusMessage: 'Generation log entry not found' })
return entry
}
export async function recordGenerationQueued(params: {
ownerKey: string
studioJobId: string
kind: StudioJobKind
payload: StudioJobPayload
}) {
const now = Date.now()
return mutate(params.ownerKey, (entries) => {
const existing = entries.find(item => item.studioJobId === params.studioJobId)
if (existing) {
existing.updatedAt = now
existing.status = 'queued'
existing.payload = structuredClone(params.payload)
existing.name = snippetName(params.payload, params.kind)
existing.prompt = params.payload.prompt || existing.prompt
existing.kind = params.kind
existing.lastError = undefined
entries.splice(entries.indexOf(existing), 1)
entries.unshift(existing)
return existing
}
const entry: GenerationLogEntry = {
id: crypto.randomUUID(),
studioJobId: params.studioJobId,
createdAt: now,
updatedAt: now,
kind: params.kind,
name: snippetName(params.payload, params.kind),
prompt: params.payload.prompt || '',
status: 'queued',
payload: structuredClone(params.payload)
}
entries.unshift(entry)
entries.splice(MAX_ENTRIES)
return entry
})
}
export async function updateGenerationLogByStudioJob(
owner: string,
studioJobId: string,
patch: { status?: GenerationLogStatus; lastError?: string; payload?: StudioJobPayload }
) {
return mutate(owner, (entries) => {
const entry = entries.find(item => item.studioJobId === studioJobId)
if (!entry) return null
entry.updatedAt = Date.now()
if (patch.status) entry.status = patch.status
if (patch.lastError !== undefined) entry.lastError = patch.lastError || undefined
if (patch.payload) {
entry.payload = structuredClone(patch.payload)
entry.name = snippetName(patch.payload, entry.kind)
entry.prompt = patch.payload.prompt || entry.prompt
}
return structuredClone(entry)
})
}
export async function requeueGenerationLog(owner: string, id: string) {
const entry = getGenerationLogEntry(owner, id)
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
const studio = await addStudioJob({
ownerKey: owner,
familyId: crypto.randomUUID(),
kind: entry.kind,
payload: structuredClone(entry.payload)
})
await kickStudioQueue()
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
}
export async function deleteGenerationLogEntry(owner: string, id: string) {
return mutate(owner, (entries) => {
const index = entries.findIndex(item => item.id === id)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Generation log entry not found' })
const [removed] = entries.splice(index, 1)
return removed
})
}
+31 -2
View File
@@ -389,17 +389,34 @@ export async function addStudioJob(params: {
jobs.push(job)
return job
})
void import('~/server/utils/generationLog')
.then(({ recordGenerationQueued }) => recordGenerationQueued({
ownerKey: params.ownerKey,
studioJobId: job.id,
kind,
payload: params.payload
}))
.catch(() => null)
return job
}
export async function patchStudioJob(owner: string, id: string, patch: (job: StudioJob) => void) {
return mutate(owner, (jobs) => {
const updated = await mutate(owner, (jobs) => {
const job = jobs.find(item => item.id === id)
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
patch(job)
job.updatedAt = Date.now()
return structuredClone(job)
})
if (updated.status === 'error' || updated.status === 'running' || updated.status === 'cancelled' || updated.status === 'complete') {
void import('~/server/utils/generationLog')
.then(({ updateGenerationLogByStudioJob }) => updateGenerationLogByStudioJob(owner, id, {
status: updated.status === 'running' ? 'running' : updated.status,
lastError: updated.lastError
}))
.catch(() => null)
}
return updated
}
function canCancelStudioStatus(status: StudioJobStatus) {
@@ -460,6 +477,9 @@ export async function cancelStudioJob(owner: string, id: string) {
}
}
kickStudioQueue()
void import('~/server/utils/generationLog')
.then(({ updateGenerationLogByStudioJob }) => updateGenerationLogByStudioJob(owner, id, { status: 'cancelled' }))
.catch(() => null)
return result
}
@@ -598,7 +618,7 @@ export async function markStudioHeld(owner: string, id: string) {
}
export async function markStudioSettled(owner: string, liveJobId: string, status: 'complete' | 'error' | 'cancelled', error?: string) {
return mutate(owner, (jobs) => {
const settled = await mutate(owner, (jobs) => {
const job = jobs.find(item => item.liveJobId === liveJobId)
if (!job) return null
if (job.holdForCutIn && status === 'complete') {
@@ -615,6 +635,15 @@ export async function markStudioSettled(owner: string, liveJobId: string, status
job.updatedAt = Date.now()
return structuredClone(job)
})
if (settled) {
void import('~/server/utils/generationLog')
.then(({ updateGenerationLogByStudioJob }) => updateGenerationLogByStudioJob(owner, settled.id, {
status: settled.status === 'held' ? 'queued' : status,
lastError: error
}))
.catch(() => null)
}
return settled
}
function pickCutIn(jobs: StudioJob[]) {