Restrict Studio 2 host cleanup to exact preview paths
This commit is contained in:
@@ -62,3 +62,9 @@ IMAGE_COMFY_LAUNCH_CMD=
|
|||||||
# NUXT_SESSION_SECRET=<generate a new preview-only secret>
|
# NUXT_SESSION_SECRET=<generate a new preview-only secret>
|
||||||
# Same Beast Comfy 8198 / control 8199; one shared GPU, distinct purge prefix.
|
# Same Beast Comfy 8198 / control 8199; one shared GPU, distinct purge prefix.
|
||||||
# Open /studio-2. Do not create a long-lived aigen-next Coolify app.
|
# Open /studio-2. Do not create a long-lived aigen-next Coolify app.
|
||||||
|
|
||||||
|
# Studio 2 cleanup requires this branch's /studio2/purge host endpoint and its
|
||||||
|
# scripts/studio2-purge.mjs + shared/studio2/contracts.mjs dependencies.
|
||||||
|
# Update/reload the host agent only when existing production tests are idle.
|
||||||
|
# Older agents are supported for generation; cleanup reports "Left on host"
|
||||||
|
# rather than falling back to the legacy purge endpoint.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createUpscaleHost } from './upscale-host.mjs'
|
import { createUpscaleHost } from './upscale-host.mjs'
|
||||||
|
import { purgeStudio2Files } from './studio2-purge.mjs'
|
||||||
import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
||||||
import { createGpuReservation } from './gpu-reservation.mjs'
|
import { createGpuReservation } from './gpu-reservation.mjs'
|
||||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||||
@@ -989,6 +990,12 @@ async function handleControl(req, res) {
|
|||||||
markWork()
|
markWork()
|
||||||
return streamFile(res, path)
|
return streamFile(res, path)
|
||||||
}
|
}
|
||||||
|
if (req.method === 'POST' && url.pathname === '/studio2/purge') {
|
||||||
|
if (!gpuReservation.isOwner(String(req.headers['x-aigen-gpu-lease'] || ''))) return json(res, 409, { ok: false, error: 'GPU lease required' })
|
||||||
|
const body = await readJson(req)
|
||||||
|
try { return json(res, 200, purgeStudio2Files(body, rootsForType)) }
|
||||||
|
catch (error) { return json(res, 400, { ok: false, error: String(error.message || error) }) }
|
||||||
|
}
|
||||||
if (req.method === 'POST' && url.pathname === '/purge') {
|
if (req.method === 'POST' && url.pathname === '/purge') {
|
||||||
const body = await readJson(req)
|
const body = await readJson(req)
|
||||||
return json(res, 200, purgeDesktopFiles(body))
|
return json(res, 200, purgeDesktopFiles(body))
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { resolve, relative, isAbsolute } from 'node:path'
|
||||||
|
import { existsSync, realpathSync, statSync, unlinkSync } from 'node:fs'
|
||||||
|
import { scopedFile } from '../shared/studio2/contracts.mjs'
|
||||||
|
|
||||||
|
/** Exact Studio 2 files only: no folder fallback, sibling deletion, or sweeping. */
|
||||||
|
export function purgeStudio2Files({prefix, files}, rootsForType) {
|
||||||
|
if (!Array.isArray(files) || !files.length || files.length>100 || !prefix) throw new Error('Invalid Studio 2 purge manifest')
|
||||||
|
const targets=[]
|
||||||
|
for (const file of files) {
|
||||||
|
if (!['input','output'].includes(file.type) || !scopedFile(file,prefix) || !String(file.subfolder).replaceAll('\\','/').startsWith(String(prefix).replace(/\/$/,'')+'/studio2/')) throw new Error('Purge file is outside the Studio 2 prefix')
|
||||||
|
const roots=rootsForType(file.type)
|
||||||
|
if(!roots.length)throw new Error('No media root is configured for cleanup')
|
||||||
|
for (const root of roots) {
|
||||||
|
const target=resolve(root,file.subfolder,file.filename),rel=relative(resolve(root),target)
|
||||||
|
if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('Purge path escapes its media root')
|
||||||
|
if (!existsSync(target)) continue
|
||||||
|
const realRoot=realpathSync(root),realTarget=realpathSync(target),realRel=relative(realRoot,realTarget)
|
||||||
|
if (!realRel || realRel.startsWith('..') || isAbsolute(realRel) || !statSync(realTarget).isFile()) throw new Error('Purge target is not a media file inside its root')
|
||||||
|
const prefixRel=relative(resolve(realRoot,prefix),realTarget)
|
||||||
|
if(!prefixRel || prefixRel.startsWith('..') || isAbsolute(prefixRel))throw new Error('Purge target resolves outside the instance prefix')
|
||||||
|
targets.push(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(const target of new Set(targets))unlinkSync(target)
|
||||||
|
return {ok:true,cleared:files.length,deleted:[...new Set(targets)]}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {readFileSync,mkdtempSync,mkdirSync,writeFileSync,existsSync} from 'node:fs'
|
||||||
|
import {tmpdir} from 'node:os'
|
||||||
|
import {join} from 'node:path'
|
||||||
|
import ts from 'typescript'
|
||||||
|
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||||
|
import {purgeStudio2Files} from '../scripts/studio2-purge.mjs'
|
||||||
|
const text=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
|
||||||
|
const tree=ts.createSourceFile('runner.ts',text,ts.ScriptTarget.Latest,true)
|
||||||
|
function runner(scope){const node=tree.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name?.text==='run');const js=ts.transpileModule(node.getText(tree),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText;return new Function(...Object.keys(scope),`${js};return run`)(...Object.values(scope))}
|
||||||
|
function fixture(mode='video') {
|
||||||
|
const phases=[],queued=[],saved=[],purged=[]
|
||||||
|
const request=validateRequest({folderId:'f',mode,engine:mode==='video'?'minimax':'flux',identityStillId:'hero',promptSections:{action:'walk'},batch:['turn','sit']})
|
||||||
|
const record={id:'r',owner:'o',request,outputs:[],queuedAt:1,startedAt:2,familyId:'family'}
|
||||||
|
const job={status:'running',clientId:'client',library:{}}
|
||||||
|
let id=0
|
||||||
|
const scope={
|
||||||
|
compilePrompt,saveRecord(){},update(r,state){r.state=state;phases.push(state)},acquireSharedGpu:async()=>true,ensureComfyReady:async()=>{},emitJob(){},
|
||||||
|
prepareGraph:async r=>{queued.push({hero:r.request.identityStillId,start:r.request.startClipId,action:r.request.promptSections.action});return {}},queuePrompt:async()=>({prompt_id:`p${++id}`}),
|
||||||
|
fetchHistory:async p=>({[p]:{status:{status_str:'success'},outputs:{}}}),comfyFetch:()=>{throw new Error('Unexpected network call')},
|
||||||
|
extractVideo:()=>({filename:'video.mp4',subfolder:'preview/studio2/r',type:'output'}),extractEditedImage:()=>({filename:'image.png',subfolder:'preview/studio2/r',type:'output'}),
|
||||||
|
downloadComfyVideo:async()=>Buffer.from('video'),downloadComfyImage:async()=>Buffer.from('image'),imageDimensions:()=>({width:960,height:960}),
|
||||||
|
videoSourcePaths:()=>['source'],clipVideoPath:()=>'/fake/source',studio2Root:()=>'/fake',join,mkdirSync(){},stitchExtension:async()=>Buffer.from('stitched'),
|
||||||
|
saveClip:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a},saveStill:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a},
|
||||||
|
attachStudio2Metadata:async(owner,id,data)=>{assert.ok(saved.some(a=>a.id===id));assert.equal(data.identityStillId,'hero')},
|
||||||
|
purge:async r=>{assert.ok(saved.length>purged.length);purged.push(r.index);r.purgeResult='Cleared input + output'},
|
||||||
|
getClip:()=>saved.at(-1),getStill:()=>saved.at(-1),persistClipAnchorFrame:async()=>{},onLiveVideoSettled:async()=>{}
|
||||||
|
}
|
||||||
|
return {scope,record,job,phases,queued,saved,purged}
|
||||||
|
}
|
||||||
|
for(const mode of ['video','iterate']) test(`${mode} runner saves each output before purge and keeps identity through the batch`,async()=>{
|
||||||
|
const f=fixture(mode);await runner(f.scope)(f.record,f.job)
|
||||||
|
assert.equal(f.record.state,'complete');assert.equal(f.saved.length,3);assert.equal(f.purged.length,3)
|
||||||
|
assert.deepEqual(f.queued.map(q=>q.hero),['hero','hero','hero']);assert.deepEqual(f.queued.map(q=>q.action),['walk','turn','sit'])
|
||||||
|
if(mode==='video')assert.deepEqual(f.queued.map(q=>q.start),['','asset0','asset1'])
|
||||||
|
assert.deepEqual(f.phases.slice(0,4),['waking','submitting','rendering','saving'])
|
||||||
|
})
|
||||||
|
test('failed library save never purges host media',async()=>{
|
||||||
|
const f=fixture();f.scope.saveClip=async()=>{throw new Error('Disk full')};await runner(f.scope)(f.record,f.job);assert.equal(f.record.state,'failed');assert.equal(f.purged.length,0)
|
||||||
|
})
|
||||||
|
test('resume uses persisted Comfy prompt and never submits it again',async()=>{
|
||||||
|
const f=fixture();f.record.request.batch=[];f.record.promptId='existing';f.record.files=[];f.record.index=0
|
||||||
|
f.scope.queuePrompt=async()=>{throw new Error('Duplicate submit')};await runner(f.scope)(f.record,f.job)
|
||||||
|
assert.equal(f.record.state,'complete');assert.equal(f.queued.length,0);assert.equal(f.saved.length,1)
|
||||||
|
})
|
||||||
|
test('strict purge leaves production and root fallback files intact',()=>{
|
||||||
|
const root=mkdtempSync(join(tmpdir(),'studio2-purge-')),prefix='video/MiniMax_H3_preview',subfolder=prefix+'/studio2/job/0'
|
||||||
|
mkdirSync(join(root,subfolder),{recursive:true});mkdirSync(join(root,'video/MiniMax_H3'),{recursive:true})
|
||||||
|
for(const path of ['same.png','video/MiniMax_H3/same.png',subfolder+'/same.png'])writeFileSync(join(root,path),'image')
|
||||||
|
const result=purgeStudio2Files({prefix,files:[{filename:'same.png',subfolder,type:'output'}]},()=>[root])
|
||||||
|
assert.equal(result.cleared,1);assert.equal(existsSync(join(root,subfolder,'same.png')),false);assert.ok(existsSync(join(root,'same.png')));assert.ok(existsSync(join(root,'video/MiniMax_H3/same.png')))
|
||||||
|
assert.throws(()=>purgeStudio2Files({prefix,files:[{filename:'same.png',subfolder:'video/MiniMax_H3',type:'output'}]},()=>[root]),/outside/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user