mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
Merge f-022-left: interrupted-move prompt (app + Terminal), deterministic disk test, engine-minimum test reads the tree version
This commit is contained in:
commit
1f727f4b52
20 files changed
+505
-38
No files matched your search
@@ -224,6 +224,26 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
const error = result.json?.error;
|
||||
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
|
||||
},
|
||||
/** A move of the models folder that the last run left unfinished (`strata models move --status`): null, or {state, from, to, bytes}. */
|
||||
async interruptedModelsMove(dataFolder) {
|
||||
const result = await call(['models', 'move', '--status', ...(dataFolder ? ['--from', dataFolder] : []), '--output', 'json'], { timeoutMs: 30_000 });
|
||||
if (result.json?.ok) return result.json.interrupted ?? null;
|
||||
throw failure(result, 'The models folder could not be checked.');
|
||||
},
|
||||
/** "Finish it": the same move again, from where it stopped. Same events and result as moveModels. */
|
||||
async resumeModelsMove(dataFolder, { installDir = null, cancelFile = null, onEvent = null } = {}) {
|
||||
const args = ['models', 'move', '--resume', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
|
||||
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
|
||||
if (result.json?.ok && result.json.event === 'result') return result.json;
|
||||
const error = result.json?.error;
|
||||
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
|
||||
},
|
||||
/** "Leave it": keep the old folder, remove the partial copies in the new one. */
|
||||
async leaveModelsMove(dataFolder, { installDir = null } = {}) {
|
||||
const result = await call(['models', 'move', '--leave', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), '--output', 'json'], { timeoutMs: 24 * 3600_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
|
||||
},
|
||||
async saveProfile(name, configPath) {
|
||||
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
|
||||
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
|
||||
|
||||
@@ -974,6 +974,9 @@ async function api(req, res, path, method, url) {
|
||||
if(picked.cancelled)return json(res,200,{cancelled:true});
|
||||
return json(res,200,{plan:await native.planModelsFolder(picked.path)});}
|
||||
if(path==='/api/native/models-folder/start'&&method==='POST'){const body=await readBody(req);const result=await native.startModelsFolder({folder:body.path,choice:body.choice,stopRunning:body.stopRunning===true});return json(res,result.id?202:200,result);}
|
||||
if(path==='/api/native/models-folder/interrupted'&&method==='GET')return json(res,200,{interrupted:await native.interruptedMove()});
|
||||
if(path==='/api/native/models-folder/resume'&&method==='POST'){const body=await readBody(req);return json(res,202,await native.finishInterruptedMove({stopRunning:body.stopRunning===true}));}
|
||||
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
|
||||
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
|
||||
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
|
||||
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
|
||||
|
||||
@@ -424,11 +424,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
setDownloadDirectory(target);lastRead=0;await refresh(true);
|
||||
return {done:true,choice,folder:target,oldFolder:old,setAside:aside};
|
||||
}
|
||||
return runModelsMove(old,target,(from,to,options)=>control.moveModels(from,to,options));
|
||||
}
|
||||
/* The move as a job (the progress dialog reads it). `run` is moveModels for a new move, resumeModelsMove for "Finish it". */
|
||||
function runModelsMove(old,target,run){
|
||||
rmSync(moveFile(),{force:true});
|
||||
return begin('models-move',async job=>{
|
||||
job.cancelFile=moveFile();job.summary='Starting…';
|
||||
try{
|
||||
const result=await control.moveModels(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
|
||||
const result=await run(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
|
||||
if(event.event!=='progress')return;
|
||||
job.progress={done:event.done,total:event.total,file:event.file,group:event.group,index:event.index,count:event.count,secondsLeft:event.seconds_left||0,sameDrive:!!event.same_drive};
|
||||
job.summary=`${Math.round(event.done/1e8)/10} of ${Math.round(event.total/1e8)/10} GB`;emit();}});
|
||||
@@ -445,10 +449,33 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
}finally{rmSync(moveFile(),{force:true});}
|
||||
},{family:undefined});
|
||||
}
|
||||
/* "A move was interrupted. Finish it or leave it": the next start finds the unfinished move (Control keeps a note in the models folder in use). */
|
||||
async function interruptedMove(){
|
||||
if(!packaged)return null;
|
||||
const now=currentModelsFolder();if(!now)return null;
|
||||
if([...jobs.values()].some(j=>j.kind==='models-move'&&j.state==='running'))return null;
|
||||
try{return await control.interruptedModelsMove(now);}catch{return null;}
|
||||
}
|
||||
async function finishInterruptedMove({stopRunning=false}={}){
|
||||
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
if(instances.size){
|
||||
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
|
||||
for(const id of [...instances.keys()])await unload(id);
|
||||
}
|
||||
return runModelsMove(st.from,st.to,(from,to,options)=>control.resumeModelsMove(from,options));
|
||||
}
|
||||
async function leaveInterruptedMove(){
|
||||
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
const result=await control.leaveModelsMove(st.from,{installDir:repo()});
|
||||
lastRead=0;await refresh(true);
|
||||
return {done:true,folder:st.from,partial:st.to,removed:result.removed??0,restored:result.restored??0};
|
||||
}
|
||||
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.kind==='models-move'){/* a safe stop: the copy in flight is dropped, files already moved stay */try{writeFileSync(job.cancelFile,'stop');}catch{/* the run ends anyway */}job.summary='Stopping…';emit();return state();}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
|
||||
async function close(){closing=true;clearInterval(timer);const installing=packaged&&child&&[...jobs.values()].some(j=>j.kind==='install'&&j.state==='running');if(installing)await send('tools/call',{name:'strata_install',arguments:{cancel:true}},15000).catch(()=>{});const operations=[];for(const job of jobs.values()){job.abort?.abort();if(job.process)operations.push(stopProcess(job.process));if(job.state==='running')job.state='cancelled';}await Promise.all(operations);await Promise.all([...instances.keys()].map(id=>unload(id,true).catch(()=>{})));disconnect();}
|
||||
// Remove nonserializable process references from public jobs.
|
||||
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
|
||||
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
|
||||
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
|
||||
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
|
||||
const [outcome, setOutcome] = useState<{ job: NativeJob } | null>(null);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [unfinished, setUnfinished] = useState<{ from: string; to: string } | null>(null);
|
||||
const [resumed, setResumed] = useState<{ from: string; to: string } | null>(null);
|
||||
const folder = native?.downloadDirectory ?? '';
|
||||
const free = native?.downloadFreeBytes ?? null;
|
||||
const usage = native?.installed?.usage?.total_bytes ?? 0;
|
||||
@@ -64,6 +66,41 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
|
||||
const used = free !== null && total ? Math.min(100, Math.max(1, ((total - free) / total) * 100)) : 0;
|
||||
useEffect(() => { if (moving && !jobId) setJobId(moving.id); }, [moving, jobId]);
|
||||
|
||||
/* A move that a crash or a stop left half done: found again at the next start, with its two choices. */
|
||||
useEffect(() => {
|
||||
if (moving || outcome) return;
|
||||
let live = true;
|
||||
api.get<{ interrupted: { from: string; to: string } | null }>('/api/native/models-folder/interrupted')
|
||||
.then(r => { if (live) setUnfinished(r.interrupted); }).catch(() => { if (live) setUnfinished(null); });
|
||||
return () => { live = false; };
|
||||
}, [moving, outcome, folder]);
|
||||
async function finishMove() {
|
||||
if (!unfinished) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<NativeJob>('/api/native/models-folder/resume', { stopRunning: true });
|
||||
setResumed(unfinished);
|
||||
setUnfinished(null);
|
||||
if (result.id) { setJobId(result.id); setOutcome(null); await refreshNative(true); }
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function leaveMove() {
|
||||
if (!unfinished) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post('/api/native/models-folder/leave', {});
|
||||
setNotice(tr('The partial copies were removed. Your models are in {0}.', [unfinished.from]));
|
||||
setUnfinished(null);
|
||||
await refreshNative(true);
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
|
||||
function leave() { setPlan(null); setStage(null); setError(null); setTyping(false); }
|
||||
async function pick(path = '') {
|
||||
setPending(true);
|
||||
@@ -106,7 +143,7 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
|
||||
try { await nativeAction(`operations/${moving.id}/cancel`); }
|
||||
catch (e) { setError({ message: (e as Error).message }); }
|
||||
}
|
||||
function closeResult() { setOutcome(null); setJobId(null); leave(); setNotice(''); }
|
||||
function closeResult() { setOutcome(null); setJobId(null); setResumed(null); leave(); setNotice(''); }
|
||||
|
||||
const progress = moving?.progress as { done: number; total: number; file: string; group: string; index: number; count: number; secondsLeft: number; sameDrive: boolean } | null | undefined;
|
||||
const percent = progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
|
||||
@@ -125,6 +162,12 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
|
||||
<input id="models-folder-path" className="st-input" value={typed} onChange={e => setTyped(e.target.value)} placeholder={tr('Full path to an existing folder')} spellCheck={false} autoFocus/>
|
||||
<div className="row"><Btn variant="primary" type="submit" disabled={pending || !typed.trim()}>{tr('Use this directory')}</Btn></div>
|
||||
</form> : null}
|
||||
{unfinished && !moving ? <div className="models-notice models-notice--info" role="alert"><Icon name="info" size={18}/><div>
|
||||
<strong>{tr('A move was interrupted. Finish it or leave it.')}</strong><br/>
|
||||
{tr('Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.', [unfinished.from, unfinished.to])}
|
||||
<p className="small muted">{tr('Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.', [unfinished.from, unfinished.to])}</p>
|
||||
<div className="row"><Btn variant="primary" size="sm" disabled={pending || blocked} onClick={() => void finishMove()}>{tr('Finish the move')}</Btn><Btn size="sm" disabled={pending || blocked} onClick={() => void leaveMove()}>{tr('Leave it')}</Btn></div>
|
||||
</div></div> : null}
|
||||
{notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{notice}</p> : null}
|
||||
{error && !stage ? <Failure message={error.message} details={error.details}/> : null}
|
||||
</section>
|
||||
@@ -155,7 +198,7 @@ export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
|
||||
<p className="small">{tr('Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.')}</p>
|
||||
{progress ? <details className="models-details"><summary>{tr('Details')}</summary><ul>
|
||||
<li>{tr('Now copying: {0}, file {1} of {2}', [modelName(progress.group, native) ?? progress.group, progress.index, progress.count])}</li>
|
||||
<li>{tr('From {0} to {1}', [savedPlan?.current ?? '', savedPlan?.folder ?? ''])}</li></ul></details> : null}
|
||||
<li>{tr('From {0} to {1}', [savedPlan?.current ?? resumed?.from ?? '', savedPlan?.folder ?? resumed?.to ?? ''])}</li></ul></details> : null}
|
||||
</Dialog>
|
||||
|
||||
<ResultDialog outcome={outcome?.job ?? null} plan={savedPlan} native={native} onClose={closeResult} onAgain={() => { closeResult(); void pick(); }}/>
|
||||
@@ -199,7 +242,7 @@ function ChooseBody({ plan, choice, setChoice, names, error }: { plan: Plan; cho
|
||||
function ResultDialog({ outcome, plan, native, onClose, onAgain }: { outcome: NativeJob | null; plan: Plan | null; native: NativeState | null; onClose: () => void; onAgain: () => void }) {
|
||||
if (!outcome) return null;
|
||||
const result = (outcome.result ?? {}) as Outcome;
|
||||
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? `${name} (${gb(m?.bytes ?? 0)})` : null; }).filter((x): x is string => !!x));
|
||||
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? (m ? `${name} (${gb(m.bytes)})` : name) : null; }).filter((x): x is string => !!x));
|
||||
if (outcome.state === 'error') {
|
||||
const full = result.code === 'disk_full';
|
||||
const where = driveOf(result.to ?? '');
|
||||
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "Ein Modell läuft. Stoppen Sie es zuerst und ändern Sie dann den Ordner.",
|
||||
"s8414": "Wählen Sie einen Modellordner.",
|
||||
"s8415": "Wählen Sie, was mit Ihren Modellen geschehen soll.",
|
||||
"s8416": "Diese Möglichkeit gibt es hier nicht."
|
||||
"s8416": "Diese Möglichkeit gibt es hier nicht.",
|
||||
"s8417": "Ein Verschieben wurde unterbrochen. Beenden Sie es oder lassen Sie es sein.",
|
||||
"s8418": "Strata hat Ihre Modelle von {0} nach {1} verschoben, als es angehalten wurde. Es ist nichts verloren.",
|
||||
"s8419": "Verschieben beenden",
|
||||
"s8420": "Belassen",
|
||||
"s8421": "Beenden legt alle Ihre Modelle in {1} ab. Belassen lässt sie in {0} und entfernt die unvollständigen Kopien aus {1}.",
|
||||
"s8422": "Die unvollständigen Kopien wurden entfernt. Ihre Modelle liegen in {0}."
|
||||
}
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "A model is running. Stop it first, then change the folder.",
|
||||
"s8414": "Choose a models folder.",
|
||||
"s8415": "Choose what to do with your models.",
|
||||
"s8416": "That choice is not possible here."
|
||||
"s8416": "That choice is not possible here.",
|
||||
"s8417": "A move was interrupted. Finish it or leave it.",
|
||||
"s8418": "Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.",
|
||||
"s8419": "Finish the move",
|
||||
"s8420": "Leave it",
|
||||
"s8421": "Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.",
|
||||
"s8422": "The partial copies were removed. Your models are in {0}."
|
||||
}
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "Hay un modelo en ejecución. Deténlo primero y después cambia la carpeta.",
|
||||
"s8414": "Elige una carpeta de modelos.",
|
||||
"s8415": "Elige qué hacer con tus modelos.",
|
||||
"s8416": "Esa opción no es posible aquí."
|
||||
"s8416": "Esa opción no es posible aquí.",
|
||||
"s8417": "Se interrumpió un traslado. Termínelo o déjelo como estaba.",
|
||||
"s8418": "Strata estaba trasladando sus modelos de {0} a {1} cuando se detuvo. No se ha perdido nada.",
|
||||
"s8419": "Terminar el traslado",
|
||||
"s8420": "Dejarlo",
|
||||
"s8421": "Terminar pone todos sus modelos en {1}. Dejarlo los mantiene en {0} y elimina las copias parciales de {1}.",
|
||||
"s8422": "Se eliminaron las copias parciales. Sus modelos están en {0}."
|
||||
}
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "モデルが実行中です。先に停止してから、フォルダを変更してください。",
|
||||
"s8414": "モデルフォルダを選んでください。",
|
||||
"s8415": "モデルをどうするか選んでください。",
|
||||
"s8416": "この選択肢はここでは使えません。"
|
||||
"s8416": "この選択肢はここでは使えません。",
|
||||
"s8417": "移動が中断されました。完了するか、中止してください。",
|
||||
"s8418": "Strata がモデルを {0} から {1} へ移動している途中で止まりました。失われたものはありません。",
|
||||
"s8419": "移動を完了する",
|
||||
"s8420": "中止する",
|
||||
"s8421": "完了すると、すべてのモデルが {1} に入ります。中止すると、モデルは {0} に残り、{1} の不完全なコピーが削除されます。",
|
||||
"s8422": "不完全なコピーを削除しました。モデルは {0} にあります。"
|
||||
}
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "모델이 실행 중입니다. 먼저 중지한 뒤 폴더를 변경하세요.",
|
||||
"s8414": "모델 폴더를 선택하세요.",
|
||||
"s8415": "모델을 어떻게 할지 선택하세요.",
|
||||
"s8416": "여기서는 해당 선택지를 사용할 수 없습니다."
|
||||
"s8416": "여기서는 해당 선택지를 사용할 수 없습니다.",
|
||||
"s8417": "이동이 중단되었습니다. 마무리하거나 그대로 두세요.",
|
||||
"s8418": "Strata가 모델을 {0}에서 {1}(으)로 옮기던 중 멈췄습니다. 잃어버린 것은 없습니다.",
|
||||
"s8419": "이동 마무리",
|
||||
"s8420": "그대로 두기",
|
||||
"s8421": "마무리하면 모든 모델이 {1}에 들어갑니다. 그대로 두면 모델은 {0}에 남고 {1}의 불완전한 복사본이 삭제됩니다.",
|
||||
"s8422": "불완전한 복사본을 삭제했습니다. 모델은 {0}에 있습니다."
|
||||
}
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "Há um modelo em execução. Pare-o primeiro e depois altere a pasta.",
|
||||
"s8414": "Escolha uma pasta de modelos.",
|
||||
"s8415": "Escolha o que fazer com seus modelos.",
|
||||
"s8416": "Essa opção não é possível aqui."
|
||||
"s8416": "Essa opção não é possível aqui.",
|
||||
"s8417": "Uma mudança foi interrompida. Termine-a ou deixe como estava.",
|
||||
"s8418": "O Strata estava mudando seus modelos de {0} para {1} quando parou. Nada foi perdido.",
|
||||
"s8419": "Terminar a mudança",
|
||||
"s8420": "Deixar como está",
|
||||
"s8421": "Terminar coloca todos os seus modelos em {1}. Deixar como está mantém os modelos em {0} e remove as cópias parciais de {1}.",
|
||||
"s8422": "As cópias parciais foram removidas. Seus modelos estão em {0}."
|
||||
}
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "Модель запущена. Сначала остановите её, затем смените папку.",
|
||||
"s8414": "Выберите папку моделей.",
|
||||
"s8415": "Выберите, что сделать с моделями.",
|
||||
"s8416": "Этот вариант здесь невозможен."
|
||||
"s8416": "Этот вариант здесь невозможен.",
|
||||
"s8417": "Перенос был прерван. Завершите его или оставьте как есть.",
|
||||
"s8418": "Strata переносил ваши модели из {0} в {1}, когда остановился. Ничего не потеряно.",
|
||||
"s8419": "Завершить перенос",
|
||||
"s8420": "Оставить как есть",
|
||||
"s8421": "Если завершить, все модели окажутся в {1}. Если оставить, модели останутся в {0}, а неполные копии в {1} будут удалены.",
|
||||
"s8422": "Неполные копии удалены. Ваши модели находятся в {0}."
|
||||
}
|
||||
@@ -3437,5 +3437,11 @@
|
||||
"s8413": "有模型正在运行。请先停止它,再更改文件夹。",
|
||||
"s8414": "请选择模型文件夹。",
|
||||
"s8415": "请选择如何处理你的模型。",
|
||||
"s8416": "此处无法使用该选项。"
|
||||
"s8416": "此处无法使用该选项。",
|
||||
"s8417": "移动被中断了。请完成它,或保持原样。",
|
||||
"s8418": "Strata 正在把你的模型从 {0} 移到 {1} 时停止了。没有任何内容丢失。",
|
||||
"s8419": "完成移动",
|
||||
"s8420": "保持原样",
|
||||
"s8421": "完成后,所有模型都会放到 {1}。保持原样则模型留在 {0},并删除 {1} 中不完整的副本。",
|
||||
"s8422": "已删除不完整的副本。你的模型在 {0}。"
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { createNativeControl } from '../server/native-control.mjs';
|
||||
import { createControl } from '../server/control-runtime.mjs';
|
||||
|
||||
/** The host in the packaged app with a stand-in for Strata Control: the move itself is Control's (tests/terminal/test_models_move.py). */
|
||||
function fixture(t, { plan = {}, move = null } = {}) {
|
||||
function fixture(t, { plan = {}, move = null, unfinished = null } = {}) {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'strata-mfolder-')));
|
||||
const install = join(root, 'install'), data = join(root, 'host'), oldFolder = join(root, 'old'), newFolder = join(root, 'new');
|
||||
for (const d of [install, data, oldFolder, newFolder]) mkdirSync(d, { recursive: true });
|
||||
@@ -34,6 +34,13 @@ function fixture(t, { plan = {}, move = null } = {}) {
|
||||
options.onEvent({ event: 'progress', done: 5e9, total: 74e9, file: 'models/gyro-s/a.gguf', group: 'gyro-s', index: 1, count: 3, seconds_left: 300, same_drive: false });
|
||||
return { ok: true, event: 'result', done: true, stopped: false, moved: ['gyro-s', 'coder'], remaining: [], bytes_moved: 74e9, total_bytes: 74e9, files_moved: 3, same_drive: false };
|
||||
},
|
||||
async interruptedModelsMove(from) { calls.push(['status', from]); return unfinished; },
|
||||
async resumeModelsMove(from, options) {
|
||||
calls.push(['resume', from, options.installDir]);
|
||||
options.onEvent({ event: 'progress', done: 20e9, total: 74e9, file: 'models/coder/c.gguf', group: 'coder', index: 2, count: 3, seconds_left: 120, same_drive: false });
|
||||
return { ok: true, event: 'result', done: true, stopped: false, moved: ['coder'], remaining: [], bytes_moved: 74e9, total_bytes: 74e9, files_moved: 2, same_drive: false };
|
||||
},
|
||||
async leaveModelsMove(from, options) { calls.push(['leave', from, options.installDir]); return { ok: true, removed: 1, restored: 1, bytes_removed: 5 }; },
|
||||
async stop() { return { ok: true }; },
|
||||
};
|
||||
const spawnProcess = () => { throw new Error('the host starts no process here'); };
|
||||
@@ -160,11 +167,45 @@ test('a move needs the app: the Control command line is built from the plan and
|
||||
await assert.rejects(failing.moveModels('/o', '/n'), e => e.code === 'disk_full' && e.details === 'ENOSPC' && /ran out of space/.test(e.message));
|
||||
});
|
||||
|
||||
test('an unfinished move is found at the next start, and Finish resumes it as a job that changes the folder only when done', async t => {
|
||||
const f = fixture(t);
|
||||
f.control.interruptedModelsMove = async from => { f.calls.push(['status', from]); return { state: 'failed', from: f.oldFolder, to: f.newFolder, bytes: 74e9 }; };
|
||||
assert.equal((await f.native.interruptedMove()).to, f.newFolder);
|
||||
assert.deepEqual(f.calls[0], ['status', f.oldFolder]);
|
||||
const job = await f.native.finishInterruptedMove({ stopRunning: true });
|
||||
const done = await finish(f.native, job.id);
|
||||
assert.equal(done.state, 'done');
|
||||
assert.equal(f.calls.find(c => c[0] === 'resume')[1], f.oldFolder);
|
||||
assert.equal(f.native.state().downloadDirectory, f.newFolder);
|
||||
});
|
||||
|
||||
test('Leave it keeps the old folder and asks Control to remove the partial copies; nothing interrupted means nothing to do', async t => {
|
||||
const f = fixture(t);
|
||||
assert.equal(await f.native.interruptedMove(), null);
|
||||
await assert.rejects(f.native.leaveInterruptedMove(), /No move was interrupted/);
|
||||
f.control.interruptedModelsMove = async () => ({ state: 'running', from: f.oldFolder, to: f.newFolder, bytes: 1 });
|
||||
const result = await f.native.leaveInterruptedMove();
|
||||
assert.equal(result.folder, f.oldFolder);
|
||||
assert.deepEqual(f.calls.find(c => c[0] === 'leave').slice(0, 2), ['leave', f.oldFolder]);
|
||||
assert.equal(f.native.state().downloadDirectory, f.oldFolder);
|
||||
});
|
||||
|
||||
test('the Control command lines for status, resume and leave', async () => {
|
||||
const seen = [];
|
||||
const control = createControl({ control: { dir: '/c', python: '/c/py', cli: '/c/strata.py' }, run: async args => { seen.push(args); return args.includes('--status') ? { code: 0, json: { ok: true, interrupted: { from: '/o', to: '/n' } }, text: '', stderr: '' } : args.includes('--leave') ? { code: 0, json: { ok: true, removed: 0 }, text: '', stderr: '' } : { code: 0, json: { ok: true, event: 'result', done: true }, text: '', stderr: '' }; } });
|
||||
assert.equal((await control.interruptedModelsMove('/o')).to, '/n');
|
||||
await control.resumeModelsMove('/o', { installDir: '/i', cancelFile: '/c.cancel' });
|
||||
await control.leaveModelsMove('/o', { installDir: '/i' });
|
||||
assert.deepEqual(seen[0], ['models', 'move', '--status', '--from', '/o', '--output', 'json']);
|
||||
assert.deepEqual(seen[1], ['models', 'move', '--resume', '--from', '/o', '--install-dir', '/i', '--cancel-file', '/c.cancel', '--output', 'jsonl']);
|
||||
assert.deepEqual(seen[2], ['models', 'move', '--leave', '--from', '/o', '--install-dir', '/i', '--output', 'json']);
|
||||
});
|
||||
|
||||
test('the models folder strings exist in all 8 languages with the same placeholders', () => {
|
||||
const read = lang => JSON.parse(readFileSync(new URL(`../src/locales/${lang}.json`, import.meta.url), 'utf8'));
|
||||
const en = read('en');
|
||||
const ours = Object.keys(en).filter(k => Number(k.slice(1)) >= 8339 && Number(k.slice(1)) <= 8416);
|
||||
assert.equal(ours.length, 78);
|
||||
const ours = Object.keys(en).filter(k => Number(k.slice(1)) >= 8339 && Number(k.slice(1)) <= 8422);
|
||||
assert.equal(ours.length, 84);
|
||||
for (const lang of ['de', 'es', 'ja', 'ko', 'pt', 'ru', 'zh']) {
|
||||
const text = read(lang);
|
||||
for (const key of ours) {
|
||||
|
||||
+54
-1
@@ -160,7 +160,10 @@ def parser() -> argparse.ArgumentParser:
|
||||
help="inspect only; adoption requires a qualified model workflow")
|
||||
models_import.add_argument("--output", choices=("human", "json"), default="human")
|
||||
models_move = models_sp.add_parser("move", help="move the model files (models, packs, mtp) to another folder: copied, checked, then the old copies removed")
|
||||
models_move.add_argument("to", metavar="NEW_FOLDER")
|
||||
models_move.add_argument("to", metavar="NEW_FOLDER", nargs="?", help="not needed with --resume, --leave or --status")
|
||||
models_move.add_argument("--resume", action="store_true", help="finish a move that was interrupted")
|
||||
models_move.add_argument("--leave", action="store_true", help="give up an interrupted move: keep the old folder and remove the partial copies")
|
||||
models_move.add_argument("--status", action="store_true", help="say whether a move was interrupted (changes nothing)")
|
||||
models_move.add_argument("--from", dest="source", metavar="OLD_FOLDER", help="the folder the models are in now (default: the data folder in use)")
|
||||
models_move.add_argument("--install-dir", help="absolute path to a Strata installation whose run configs should follow the files")
|
||||
models_move.add_argument("--plan", action="store_true", help="say what would happen and change nothing")
|
||||
@@ -613,6 +616,11 @@ def _models_move(a: argparse.Namespace) -> int:
|
||||
from strata_control import discovery, models, models_move as mm
|
||||
machine = a.output in ("json", "jsonl")
|
||||
old = Path(a.source) if a.source else discovery.data_dir(Path.home() / "Strata")[0]
|
||||
if a.status or a.resume or a.leave:
|
||||
return _models_move_unfinished(a, old)
|
||||
if not a.to:
|
||||
print("strata: name the new folder, or use --resume / --leave / --status for an interrupted move.", file=sys.stderr)
|
||||
return 2
|
||||
new = Path(a.to).expanduser()
|
||||
if a.plan:
|
||||
result = mm.plan(old, new)
|
||||
@@ -650,6 +658,51 @@ def _models_move(a: argparse.Namespace) -> int:
|
||||
return 0 if summary["done"] else 6
|
||||
|
||||
|
||||
def _interrupted_words(st: dict) -> str:
|
||||
return (f"A move of your models was interrupted. They are in {st['from']} and {st['to']}. "
|
||||
"Finish it: strata models move --resume. Leave it (keep the models in the first folder): strata models move --leave.")
|
||||
|
||||
|
||||
def _models_move_unfinished(a: argparse.Namespace, old: Path) -> int:
|
||||
"""`strata models move --status | --resume | --leave`: the move the last run left unfinished (A move was interrupted. Finish it or leave it)."""
|
||||
from strata_control import models, models_move as mm
|
||||
machine = a.output in ("json", "jsonl")
|
||||
st = mm.interrupted(old)
|
||||
if a.status:
|
||||
if machine:
|
||||
print(json.dumps({"schema_version": 1, "ok": True, "interrupted": st}, ensure_ascii=False, allow_nan=False))
|
||||
else:
|
||||
print(_interrupted_words(st) if st else "No move was interrupted.")
|
||||
return 0
|
||||
if not st:
|
||||
if machine:
|
||||
print(json.dumps({"schema_version": 1, "ok": False, "error": {"code": "nothing", "message": "No move was interrupted.", "details": ""}}))
|
||||
else:
|
||||
print("strata: no move was interrupted.", file=sys.stderr)
|
||||
return 5
|
||||
try:
|
||||
installs = [models.resolve_install(a.install_dir)[0]] if a.install_dir else [p for p, _s in models.all_installs()]
|
||||
except Exception:
|
||||
installs = []
|
||||
configs = [c for i in installs for c in mm.config_files(i)]
|
||||
if a.leave:
|
||||
try:
|
||||
result = mm.leave(Path(st["from"]), Path(st["to"]), configs=configs)
|
||||
except mm.MoveError as e:
|
||||
if machine:
|
||||
print(json.dumps({"schema_version": 1, "ok": False, "error": {"code": e.code, "message": str(e), "details": e.details}}, ensure_ascii=False))
|
||||
else:
|
||||
print(f"strata: {e} {e.details}".rstrip(), file=sys.stderr)
|
||||
return 5
|
||||
if machine:
|
||||
print(json.dumps({"schema_version": 1, "ok": True, "event": "result", "left": True, **result}, ensure_ascii=False))
|
||||
else:
|
||||
print(f"Done. Your models stay in {st['from']}; the partial copies in {st['to']} were removed.")
|
||||
return 0
|
||||
a.to, a.source, a.resume, a.status, a.leave = st["to"], st["from"], False, False, False
|
||||
return _models_move(a)
|
||||
|
||||
|
||||
def _import_words(plan: dict) -> str:
|
||||
"""The plain-words result of `strata models import --plan`: what the files are, what is already set up, what changes (nothing)."""
|
||||
cand = plan.get("candidate_selection") or {}
|
||||
|
||||
@@ -317,6 +317,17 @@ def _say(line: str = "") -> None:
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _interrupted_move_notice() -> None:
|
||||
"""One line when a move of the models folder was left unfinished (never blocks the launch)."""
|
||||
try:
|
||||
from strata_control import discovery, models_move
|
||||
st = models_move.interrupted(discovery.data_dir(Path.home() / "Strata")[0])
|
||||
except Exception:
|
||||
return
|
||||
if st:
|
||||
_say("A move of your models was interrupted. Finish it with `strata models move --resume`, or keep them where they are with `strata models move --leave`.")
|
||||
|
||||
|
||||
def _printer(quiet: bool = False):
|
||||
"""An emit callback that prints readable lines: new text once, progress in steps of 10% when not on a terminal."""
|
||||
state = {"last": None, "pct": -10, "stage": None}
|
||||
@@ -547,6 +558,8 @@ def run_launch(a: argparse.Namespace) -> int:
|
||||
if a.model or a.model_path:
|
||||
return fail(str(e), 5)
|
||||
s, notices = settings_from_args(a, catalog)
|
||||
if not machine:
|
||||
_interrupted_move_notice()
|
||||
have_choices = bool(a.preset or a.model or a.model_path or a.context or a.vision or a.parallel or a.access or a.tune)
|
||||
interactive = sys.stdin.isatty() and sys.stdout.isatty() and not a.no_tui and not machine and not a.plan
|
||||
plain_questions = False
|
||||
|
||||
+106
-13
@@ -272,12 +272,6 @@ def _read_state(new: Path) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def interrupted(data: Path) -> dict | None:
|
||||
"""The unfinished move recorded in a data folder (``models-move.json``), for 'A move was interrupted'."""
|
||||
st = _read_state(data)
|
||||
return st if st.get("state") == "running" else None
|
||||
|
||||
|
||||
def _write_state(new: Path, **fields) -> None:
|
||||
try:
|
||||
tmp = new / (STATE_FILE + ".tmp")
|
||||
@@ -287,6 +281,50 @@ def _write_state(new: Path, **fields) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _save_state(old: Path, new: Path, **fields) -> None:
|
||||
"""The state goes in the new folder and, as a pointer, in the old one: the old folder stays the data folder until the move is done,
|
||||
so that is where the next start looks."""
|
||||
_write_state(new, **fields)
|
||||
_write_state(old, **fields)
|
||||
|
||||
|
||||
def _clear_state(*folders: Path) -> None:
|
||||
for f in folders:
|
||||
_quiet_remove(Path(f) / STATE_FILE)
|
||||
|
||||
|
||||
def _same_path(a, b) -> bool:
|
||||
try:
|
||||
return _same(Path(a), Path(b))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def interrupted(data: Path) -> dict | None:
|
||||
"""The unfinished move recorded in the data folder (``models-move.json``), for 'A move was interrupted. Finish it or leave it'.
|
||||
|
||||
A move that is running right now (its process is alive) is not interrupted. A stopped or failed move that left files in both folders is.
|
||||
Returns ``{state, from, to, bytes}`` or None.
|
||||
"""
|
||||
data = Path(data)
|
||||
st = _read_state(data)
|
||||
if st.get("state") not in ("running", "stopped", "failed") or not st.get("from") or not st.get("to"):
|
||||
return None
|
||||
if not _same_path(st["from"], data): # a pointer left in some other folder is not ours
|
||||
return None
|
||||
if not Path(st["from"]).is_dir():
|
||||
return None
|
||||
pid, token = st.get("pid"), st.get("token")
|
||||
if st["state"] == "running" and isinstance(pid, int) and token and pid != os.getpid():
|
||||
try:
|
||||
from strata_control import procs
|
||||
if procs.start_token(pid) == token:
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
return st
|
||||
|
||||
|
||||
def repoint_config(cfg_file: Path, old: Path, new: Path) -> bool:
|
||||
"""A run config whose model files moved from ``old`` to ``new`` names them there (each path only when its file is now there and no longer
|
||||
at the old place). Same rule as setup.py repoint_config. Returns whether the file changed."""
|
||||
@@ -366,8 +404,17 @@ def move(old: Path, new: Path, *, on_progress: Callable[[dict], None] | None = N
|
||||
todo[e.group].append(e)
|
||||
left = {g: len(v) for g, v in todo.items()}
|
||||
moved_groups: list[str] = []
|
||||
state = {"state": "running", "from": str(old), "to": str(new), "started": now(), "bytes": total}
|
||||
_write_state(new, **state)
|
||||
before = _read_state(old)
|
||||
if not (_same_path(before.get("from", ""), old) and _same_path(before.get("to", ""), new)):
|
||||
before = {}
|
||||
try:
|
||||
from strata_control import procs
|
||||
token = procs.start_token(os.getpid())
|
||||
except Exception:
|
||||
token = None
|
||||
state = {"state": "running", "from": str(old), "to": str(new), "started": now(), "bytes": total, "pid": os.getpid(), "token": token,
|
||||
"files": sorted({e.rel for e in entries} | set(before.get("files") or []))}
|
||||
_save_state(old, new, **state)
|
||||
done_bytes = 0
|
||||
files_moved = 0
|
||||
last_emit = [0.0]
|
||||
@@ -459,22 +506,68 @@ def move(old: Path, new: Path, *, on_progress: Callable[[dict], None] | None = N
|
||||
repoint()
|
||||
repoint()
|
||||
state.update(state="done", finished=now())
|
||||
_write_state(new, **state)
|
||||
_quiet_remove(new / STATE_FILE)
|
||||
_clear_state(old, new)
|
||||
return {"done": True, "stopped": False, "moved": moved_groups, "remaining": [], "bytes_moved": done_bytes, "total_bytes": total,
|
||||
"files_moved": files_moved, "same_drive": same}
|
||||
except Stopped:
|
||||
repoint()
|
||||
state.update(state="stopped")
|
||||
_write_state(new, **state)
|
||||
_quiet_remove(new / STATE_FILE) if not moved_groups and not files_moved else None
|
||||
if files_moved:
|
||||
_save_state(old, new, **state)
|
||||
else:
|
||||
_clear_state(old, new)
|
||||
remaining = [g for g, n in left.items() if n > 0]
|
||||
return {"done": False, "stopped": True, "moved": moved_groups, "remaining": remaining, "bytes_moved": done_bytes, "total_bytes": total,
|
||||
"files_moved": files_moved, "same_drive": same}
|
||||
except MoveError as err:
|
||||
repoint()
|
||||
state.update(state="failed", error=err.code)
|
||||
_write_state(new, **state)
|
||||
if files_moved:
|
||||
_save_state(old, new, **state)
|
||||
else:
|
||||
_clear_state(old, new)
|
||||
err.summary = {"moved": moved_groups, "remaining": [g for g, n in left.items() if n > 0], "bytes_moved": done_bytes, "total_bytes": total,
|
||||
"files_moved": files_moved}
|
||||
raise
|
||||
|
||||
|
||||
def leave(old: Path, new: Path, *, configs: list[Path] | None = None) -> dict:
|
||||
"""'Leave it': give up an unfinished move and keep the old folder as the complete one.
|
||||
|
||||
Only the files the move itself listed are touched. A copy in the new folder whose original is still in the old one is removed; a file
|
||||
that exists only in the new folder (its original went after a checked copy) is put back in the old folder, so nothing is lost.
|
||||
``.part`` files are removed and the run configs point at the old folder again. Returns ``{removed, restored, bytes_removed}``.
|
||||
"""
|
||||
old, new = Path(old), Path(new)
|
||||
st = _read_state(old) or _read_state(new)
|
||||
rels = [r for r in (st.get("files") or []) if isinstance(r, str) and r and ".." not in Path(r).parts and not Path(r).is_absolute()]
|
||||
removed = restored = bytes_removed = 0
|
||||
for rel in rels:
|
||||
src, dst = old / rel, new / rel
|
||||
_quiet_remove(Path(str(dst) + ".part"))
|
||||
if not dst.exists():
|
||||
continue
|
||||
try:
|
||||
size = dst.stat().st_size
|
||||
if src.exists():
|
||||
os.remove(dst)
|
||||
removed += 1
|
||||
bytes_removed += size
|
||||
else:
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
if same_drive(old, new):
|
||||
os.replace(dst, src)
|
||||
else:
|
||||
_copy_verified(dst, src, size, lambda: False, lambda n: None)
|
||||
os.remove(dst)
|
||||
restored += 1
|
||||
except OSError as e:
|
||||
raise MoveError("io", f"Strata could not clean up {dst.name}.", f"{type(e).__name__}: {e}") from e
|
||||
_prune(dst.parent, new)
|
||||
for cfg in (configs or []):
|
||||
try:
|
||||
repoint_config(cfg, new, old)
|
||||
except OSError:
|
||||
pass
|
||||
_clear_state(old, new)
|
||||
return {"removed": removed, "restored": restored, "bytes_removed": bytes_removed}
|
||||
@@ -52,6 +52,22 @@ class MoveCase(unittest.TestCase):
|
||||
"""Another drive: the files are copied, whatever this machine's volumes are."""
|
||||
return mock.patch.object(mm, "same_drive", return_value=False)
|
||||
|
||||
def break_after(self, n):
|
||||
"""A copy that works n times, then fails: a move cut off part way."""
|
||||
real, calls = mm._copy_verified, [0]
|
||||
|
||||
def flaky(*args, **kw):
|
||||
calls[0] += 1
|
||||
if calls[0] > n:
|
||||
raise mm.MoveError("io", "cut off")
|
||||
return real(*args, **kw)
|
||||
return mock.patch.object(mm, "_copy_verified", side_effect=flaky)
|
||||
|
||||
def cut_off(self, n=1):
|
||||
with self.copy_mode(), self.break_after(n):
|
||||
with self.assertRaises(mm.MoveError):
|
||||
self.run_move()
|
||||
|
||||
def run_move(self, **kw):
|
||||
events = []
|
||||
summary = mm.move(self.old, self.new, on_progress=events.append, configs=[self.config], **kw)
|
||||
@@ -222,13 +238,68 @@ class MoveTests(MoveCase):
|
||||
self.check_whole()
|
||||
|
||||
def test_an_interrupted_move_is_remembered(self):
|
||||
self.cut_off(1)
|
||||
st = mm.interrupted(self.old) # the old folder is still the data folder: it holds the pointer
|
||||
self.assertEqual(st["to"], str(self.new))
|
||||
self.assertEqual(st["state"], "failed")
|
||||
self.assertEqual(mm.interrupted(self.base / "elsewhere"), None)
|
||||
mm._write_state(self.old, state="running", pid=os.getpid() + 7, token=None, **{"from": str(self.old), "to": str(self.new)})
|
||||
self.assertEqual(mm.interrupted(self.old)["state"], "running")
|
||||
|
||||
def test_a_move_that_is_running_now_is_not_interrupted(self):
|
||||
from strata_control import procs
|
||||
mm._write_state(self.old, state="running", pid=os.getpid() + 7, token="t", **{"from": str(self.old), "to": str(self.new)})
|
||||
with mock.patch.object(procs, "start_token", return_value="t"):
|
||||
self.assertIsNone(mm.interrupted(self.old))
|
||||
with mock.patch.object(procs, "start_token", return_value=None):
|
||||
self.assertIsNotNone(mm.interrupted(self.old))
|
||||
|
||||
def test_nothing_moved_leaves_nothing_to_ask_about(self):
|
||||
self.cut_off(0)
|
||||
self.assertIsNone(mm.interrupted(self.old))
|
||||
self.assertFalse((self.new / mm.STATE_FILE).exists())
|
||||
|
||||
def test_finish_resumes_the_move(self):
|
||||
self.cut_off(1)
|
||||
st = mm.interrupted(self.old)
|
||||
with self.copy_mode():
|
||||
with self.assertRaises(mm.MoveError):
|
||||
with mock.patch.object(mm, "_copy_verified", side_effect=mm.MoveError("io", "x")):
|
||||
self.run_move()
|
||||
self.assertEqual(mm.interrupted(self.new), None) # failed is not "running"
|
||||
mm._write_state(self.new, state="running", **{"from": str(self.old), "to": str(self.new)})
|
||||
self.assertEqual(mm.interrupted(self.new)["from"], str(self.old))
|
||||
summary = mm.move(Path(st["from"]), Path(st["to"]), configs=[self.config])
|
||||
self.assertTrue(summary["done"])
|
||||
self.check_whole()
|
||||
self.assertIsNone(mm.interrupted(self.old))
|
||||
self.assertFalse((self.old / mm.STATE_FILE).exists() or (self.new / mm.STATE_FILE).exists())
|
||||
|
||||
def test_leave_keeps_the_old_folder_complete(self):
|
||||
self.cut_off(1)
|
||||
(self.new / "models/stray.gguf.part").write_bytes(b"x") # not listed by the move, but a .part of ours is
|
||||
(self.new / "models/gyro-s").mkdir(parents=True, exist_ok=True)
|
||||
(self.new / "models/gyro-s/a.gguf.part").write_bytes(b"cut")
|
||||
theirs = self.new / "models/own/mine.gguf" # a file of the person's, not part of the move
|
||||
theirs.parent.mkdir(parents=True)
|
||||
theirs.write_bytes(b"mine")
|
||||
st = mm.interrupted(self.old)
|
||||
with self.copy_mode():
|
||||
result = mm.leave(Path(st["from"]), Path(st["to"]), configs=[self.config])
|
||||
self.assertGreaterEqual(result["restored"] + result["removed"], 1)
|
||||
for rel, data in self.files.items():
|
||||
self.assertEqual((self.old / rel).read_bytes(), data, rel)
|
||||
self.assertFalse((self.new / rel).exists(), rel)
|
||||
self.assertEqual(list(self.new.rglob("*.part")), [(self.new / "models/stray.gguf.part")])
|
||||
self.assertTrue(theirs.exists())
|
||||
self.assertIsNone(mm.interrupted(self.old))
|
||||
self.assertFalse((self.new / mm.STATE_FILE).exists())
|
||||
cfg = json.loads(self.config.read_text())
|
||||
self.assertTrue(all(str(self.new) not in str(v) for v in cfg.values()))
|
||||
self.assertIn(str(self.old / "models/gyro-s/a.gguf"), cfg["args"])
|
||||
|
||||
def test_leave_after_a_crash_removes_the_duplicate(self):
|
||||
self.cut_off(1)
|
||||
dup = self.new / "models/coder/c.gguf" # copied and renamed, original not yet removed
|
||||
dup.parent.mkdir(parents=True, exist_ok=True)
|
||||
dup.write_bytes(self.files["models/coder/c.gguf"])
|
||||
mm.leave(self.old, self.new)
|
||||
self.assertFalse(dup.exists())
|
||||
self.assertTrue((self.old / "models/coder/c.gguf").exists())
|
||||
|
||||
def test_configs_are_rewritten_only_for_files_that_are_there(self):
|
||||
self.config.write_text(json.dumps({"args": ["--native", str(self.old / "models/gone.gguf")]}))
|
||||
@@ -265,6 +336,52 @@ class CommandLineTests(MoveCase):
|
||||
self.assertTrue(lines[-1]["done"])
|
||||
self.assertTrue((self.new / "models/coder/c.gguf").exists())
|
||||
|
||||
def run_cli(self, *argv):
|
||||
from strata_cli import cli
|
||||
from strata_control import discovery
|
||||
import contextlib
|
||||
import io
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out), self.copy_mode(), mock.patch.object(discovery, "data_dir", return_value=(self.old, "settings")):
|
||||
code = cli.main(["models", "move", *argv])
|
||||
return code, out.getvalue()
|
||||
|
||||
def test_status_resume_and_leave(self):
|
||||
code, text = self.run_cli("--status", "--output", "json")
|
||||
self.assertEqual((code, json.loads(text)["interrupted"]), (0, None))
|
||||
self.assertEqual(self.run_cli("--resume", "--output", "json")[0], 5)
|
||||
self.cut_off(1)
|
||||
code, text = self.run_cli("--status", "--output", "json")
|
||||
self.assertEqual(json.loads(text)["interrupted"]["to"], str(self.new))
|
||||
self.assertIn("--resume", self.run_cli("--status")[1])
|
||||
code, text = self.run_cli("--leave", "--install-dir", str(self.install), "--output", "json")
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertTrue((self.old / "models/coder/c.gguf").exists())
|
||||
self.cut_off(1)
|
||||
code, text = self.run_cli("--resume", "--install-dir", str(self.install), "--output", "jsonl")
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertTrue([json.loads(l) for l in text.splitlines() if l.startswith("{")][-1]["done"])
|
||||
self.assertTrue((self.new / "models/coder/c.gguf").exists())
|
||||
self.assertFalse((self.old / "models/coder/c.gguf").exists())
|
||||
|
||||
def test_launch_says_it_in_one_line(self):
|
||||
from strata_cli.launch import cmd
|
||||
from strata_control import discovery
|
||||
import contextlib
|
||||
import io
|
||||
self.cut_off(1)
|
||||
with mock.patch.object(discovery, "data_dir", return_value=(self.old, "settings")):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
cmd._interrupted_move_notice()
|
||||
self.assertEqual(len(out.getvalue().strip().splitlines()), 1)
|
||||
self.assertIn("models move --resume", out.getvalue())
|
||||
mm.leave(self.old, self.new)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
cmd._interrupted_move_notice()
|
||||
self.assertEqual(out.getvalue(), "")
|
||||
|
||||
def test_the_cancel_file_stops_the_run(self):
|
||||
from strata_cli import cli
|
||||
import contextlib
|
||||
|
||||
@@ -442,7 +442,7 @@ class SetupIntegration(unittest.TestCase):
|
||||
self.assertNotIn("an earlier start", out.getvalue())
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(self.S, "ROOT", Path(self.tmp.name)), \
|
||||
mock.patch.object(self.S, "data_folder", lambda d: (Path(self.tmp.name) / "data", [])), \
|
||||
mock.patch.object(self.S, "data_folder", lambda d, *a, **k: (Path(self.tmp.name) / "data", [])), \
|
||||
mock.patch.object(self.S, "update_installed_engine", lambda *a: None), \
|
||||
mock.patch.object(sys, "argv", ["setup.py", "--calibrate", "--no-start"]), \
|
||||
contextlib.redirect_stdout(out):
|
||||
|
||||
@@ -6,6 +6,7 @@ install removing a stale strata_kq library.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -155,7 +156,12 @@ class EnginePlaceholders(unittest.TestCase):
|
||||
self.assertEqual(setup.MIN_ENGINE, (0, 1, 45))
|
||||
for m in ("UD-Q5_K_XL", "UD-Q6_K_XL", "NVFP4", "NVFP4-NVIDIA"):
|
||||
self.assertEqual(setup.MODELS[m]["engine"], (0, 1, 44), m)
|
||||
self.assertIn("0.1.45", (ROOT / "CMakeLists.txt").read_text(encoding="utf-8").split("project(strata VERSION", 1)[1][:12])
|
||||
# No minimum may name an engine newer than this tree (a placeholder for a release that does not exist yet would make setup
|
||||
# ask for an engine nobody can download). The tree's version is read from CMakeLists.txt, so this does not go stale each release.
|
||||
text = (ROOT / "CMakeLists.txt").read_text(encoding="utf-8")
|
||||
tree = tuple(int(n) for n in re.search(r"project\(strata VERSION ([0-9.]+)", text).group(1).split("."))
|
||||
for what, minimum in [("MIN_ENGINE", setup.MIN_ENGINE), ("NVFP4_ENGINE", setup.NVFP4_ENGINE)] + [(m, setup.MODELS[m]["engine"]) for m in setup.MODELS if "engine" in setup.MODELS[m]]:
|
||||
self.assertLessEqual(tuple(minimum), tree[:len(minimum)], f"{what} {minimum} is ahead of the tree {tree}")
|
||||
|
||||
|
||||
class KqLibrary(unittest.TestCase):
|
||||
|
||||
@@ -11,6 +11,7 @@ import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
@@ -186,6 +187,8 @@ class Base(unittest.TestCase):
|
||||
mock.patch.object(setup, "cpu_cores", lambda: None), # #642: not a hybrid CPU
|
||||
mock.patch.object(setup, "page_file_gb", lambda: 16.0),
|
||||
mock.patch.object(setup, "free_gb", lambda p: free),
|
||||
# whatever else asks the disk (shutil.disk_usage) gets the same answer: the tests never depend on this PC's free space
|
||||
mock.patch.object(shutil, "disk_usage", lambda p: shutil._ntuple_diskusage(int(max(free, 1000) * 1e9), int(max(free, 1000) * 1e9 - free * 1e9), int(free * 1e9))),
|
||||
mock.patch.object(setup, "rotational_disk", lambda p: None), # #605: not the test PC's disk
|
||||
mock.patch.object(setup, "is_wsl", lambda: False), # #974: the tests are not run inside WSL
|
||||
mock.patch.object(setup, "pip_install", lambda *a, **k: None),
|
||||
|
||||
Reference in new issue
Block a user