diff --git a/.env.example b/.env.example index baa7fc1..9369ff1 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,9 @@ IMAGE_COMFY_LAUNCH_CMD= # NUXT_SESSION_SECRET= # 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. + +# 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. diff --git a/scripts/comfy-host-agent.mjs b/scripts/comfy-host-agent.mjs index 54b418e..fe57401 100644 --- a/scripts/comfy-host-agent.mjs +++ b/scripts/comfy-host-agent.mjs @@ -1,4 +1,5 @@ import { createUpscaleHost } from './upscale-host.mjs' +import { purgeStudio2Files } from './studio2-purge.mjs' import { stableMemoryArgs } from './comfy-memory-policy.mjs' import { createGpuReservation } from './gpu-reservation.mjs' import { createGpuProxy } from './gpu-proxy.mjs' @@ -989,6 +990,12 @@ async function handleControl(req, res) { markWork() 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') { const body = await readJson(req) return json(res, 200, purgeDesktopFiles(body)) diff --git a/scripts/studio2-purge.mjs b/scripts/studio2-purge.mjs new file mode 100644 index 0000000..6d1b0c1 --- /dev/null +++ b/scripts/studio2-purge.mjs @@ -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)]} +} diff --git a/tests/studio2-runner.test.mjs b/tests/studio2-runner.test.mjs new file mode 100644 index 0000000..e4e90b4 --- /dev/null +++ b/tests/studio2-runner.test.mjs @@ -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/) +})