fix: 작업실 전체 점검 — 저장 경쟁 상태(가짜 충돌)·팔레트 PNG 썸네일·빈 조립 명세·중복 응답 루프

- 저장 직렬화: 자동 흐름(초안→렌더의 flushNow)과 debounce 타이머가 saveProject를 동시에 불러 같은 기준 버전으로
  PUT → 두 번째가 자기 자신의 첫 저장과 어긋나 가짜 409("렌더 실패: 저장 충돌을 먼저 해결하세요"). 큐로 직렬화하고,
  409여도 서버 내용이 내 내용과 같으면 충돌로 보지 않는다. 회귀 테스트(이전 코드에서 재현 확인)
- 비전 썸네일: 팔레트(P) 모드 PNG가 "cannot write mode P as JPEG"로 실패해 큰 이미지를 모델이 못 보던 문제 → RGB 변환
- 빈 조립 명세(경로/부품/단계 없음)는 저장하지 않음(조립 탭을 열기만 해도 프로젝트에 남던 것)
- .scad만 연결돼 있고 부품/단계가 없는 프로젝트도 조립 탭에서 초안 자동 시도(중단된 자동 연결 복구)
- 중복 add 응답에 "이미 충족됨, 반복 호출 금지" 안내 — 모델이 같은 add_task를 반복하던 LOOP WARN 완화

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-25 09:20:34 +09:00
co-authored by Claude Sonnet 5
parent c625826325
commit c4807c9400
5 changed files with 91 additions and 12 deletions
+2 -2
View File
@@ -400,7 +400,7 @@ function resolveImageContent(content: any, workspacePath: string): any {
// execFileSync — 셸을 거치지 않는다. 예전 execSync(`... "${filePath}"`)는 $(…)/백틱이 든
// 파일명(작업실 첨부파일 등 사용자 업로드 이름)이 셸에서 실행되는 명령 주입 경로였다.
const { execFileSync } = require('child_process');
const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]); img.thumbnail((512,512)); img.save(sys.stdout.buffer, 'JPEG', quality=75)`;
const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]).convert('RGB'); img.thumbnail((512,512)); img.save(sys.stdout.buffer, 'JPEG', quality=75)`;
buf = execFileSync('python3', ['-c', script, filePath], { maxBuffer: 5 * 1024 * 1024 });
} catch {
buf = null;
@@ -464,7 +464,7 @@ function resolveToolImageContent(content: string, workspacePath: string, embedVi
buf = fs.readFileSync(filePath);
} else {
const { execFileSync } = require('child_process');
const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]); img.thumbnail((600,600)); img.save(sys.stdout.buffer,'JPEG',quality=80)`;
const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]).convert('RGB'); img.thumbnail((600,600)); img.save(sys.stdout.buffer,'JPEG',quality=80)`;
buf = execFileSync('python3', ['-c', script, filePath], { maxBuffer: 5 * 1024 * 1024 });
}
if (buf && buf.length > 0) {
+6 -4
View File
@@ -19,6 +19,8 @@ import { renderAssemblyManual, resolveScadPath, ASSEMBLY_DIR_NAME, draftAssembly
// print3d_model 같은 confirm 게이트는 필요 없음.
const STATUSES = ['검토중', '주문완료', '보유'] as const;
// 중복 add 결과에 붙이는 안내 — 이게 없으면 모델이 "추가됐는지 불확실"하다며 같은 호출을 반복한다(09-25 LOOP WARN 실측)
const DUP_HINT = '요청은 이미 충족된 상태이니 같은 호출을 반복하지 말고 사용자에게 "이미 있다"고 알릴 것.';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// fmtWon/budgetSummary/formatProject는 workshop-storage.ts로 옮겨졌다(2026-09-23) —
@@ -214,7 +216,7 @@ export const workshopProjectTool = {
if (!name) return { success: false, error: 'name이 필요합니다.' };
const existing = project.parts.find(p => p.name.trim().toLowerCase() === name.toLowerCase());
if (existing) {
return { success: true, stdout: `[${project.name}] 이미 같은 이름의 부품이 등록돼 있어 추가하지 않음: ${existing.name} (${existing.status}, ${existing.qty}개). 수량/가격/상태를 바꾸려면 update_part를 쓸 것.` };
return { success: true, stdout: `[${project.name}] 이미 같은 이름의 부품이 등록돼 있어 추가하지 않음: ${existing.name} (${existing.status}, ${existing.qty}개). 수량/가격/상태를 바꾸려면 update_part를 쓸 것.${DUP_HINT}` };
}
const status = STATUSES.includes(args?.status) ? args.status : '검토중';
const part: WorkshopPart = {
@@ -259,7 +261,7 @@ export const workshopProjectTool = {
if (!text) return { success: false, error: 'task_text가 필요합니다.' };
const phase: WorkshopPhase = project.phases[foundPhase.index];
if (phase.tasks.some(t => t.text.trim().toLowerCase() === text.toLowerCase())) {
return { success: true, stdout: `[${project.name}] "${phase.name}"에 같은 작업이 이미 있어 추가하지 않음: ${text}` };
return { success: true, stdout: `[${project.name}] "${phase.name}"에 같은 작업이 이미 있어 추가하지 않음: ${text}.${DUP_HINT}` };
}
const due = String(args?.due || '').trim();
if (due && !DATE_RE.test(due)) return { success: false, error: 'due는 YYYY-MM-DD 형식이어야 합니다.' };
@@ -282,7 +284,7 @@ export const workshopProjectTool = {
const name = String(args?.new_phase_name || '').trim();
if (!name) return { success: false, error: 'new_phase_name이 필요합니다.' };
if (project.phases.some(ph => ph.name.trim().toLowerCase() === name.toLowerCase())) {
return { success: true, stdout: `[${project.name}] 같은 이름의 단계가 이미 있어 추가하지 않음: ${name}` };
return { success: true, stdout: `[${project.name}] 같은 이름의 단계가 이미 있어 추가하지 않음: ${name}.${DUP_HINT}` };
}
project.phases.push({ id: genWorkshopId('ph'), name, tasks: [] });
saveWorkshop(workspaceRoot, data);
@@ -294,7 +296,7 @@ export const workshopProjectTool = {
if (!text) return { success: false, error: 'note_text가 필요합니다.' };
const curNotes = String(project.notes || '');
if (curNotes.split('\n').some(l => l.trim() === text)) {
return { success: true, stdout: `[${project.name}] 같은 메모 줄이 이미 있어 추가하지 않음: ${text}` };
return { success: true, stdout: `[${project.name}] 같은 메모 줄이 이미 있어 추가하지 않음: ${text}.${DUP_HINT}` };
}
project.notes = String(project.notes || '').trim() ? `${String(project.notes).trim()}\n${text}` : text;
saveWorkshop(workspaceRoot, data);
+21 -2
View File
@@ -31,7 +31,8 @@ const SRC_SCAD = process.env.SRC_SCAD || '/srv/homeclaw/.smallclaw/users/papa/wo
fs.copyFileSync(SRC_SCAD, path.join(ws, 'workshop/pa/CAD/rig.scad'));
await fetch(`${base}/api/workshop/project/pa?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
id: 'pa', name: '치과용 스캐너(시험)', description: '듀얼카메라 DLP 구조광 스캐너 데스크탑 랙', notes: '', phases: [],
assembly: { scad: 'workshop/pa/CAD/rig.scad', parts: [], steps: [] }, // 사용자가 이미 연결해 둔 프로젝트(자동 연결/초안이 끼어들지 않음)
// 사용자가 이미 부품을 하나 넣어 둔 프로젝트 — 부품이 있으면 자동 초안이 끼어들지 않는다(비어 있으면 자동 초안이 채운다)
assembly: { scad: 'workshop/pa/CAD/rig.scad', parts: [{ id: 'p0', name: '베이스 플레이트', call: 'base_plate()', offset: [0, 0, -60] }], steps: [] },
parts: [{ id: 'b1', name: '베이스 플레이트', qty: 1, unitPrice: 30000, status: '보유', memo: '', links: [] }] }) });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
@@ -54,7 +55,7 @@ const SRC_SCAD = process.env.SRC_SCAD || '/srv/homeclaw/.smallclaw/users/papa/wo
const modCount = await page.locator('.asm-mods button').count();
check('스캔: 조립 모듈 목록(part_* 제외)', modCount > 5 && !(await page.locator('.asm-mods').innerText()).includes('part_'), `${modCount}개`);
for (const name of ['base_plate', 'turntable', 'support_frame', 'head_plate', 'projector']) {
for (const name of ['turntable', 'support_frame', 'head_plate', 'projector']) { // base_plate는 이미 있음
await page.click(`.asm-mods button:has-text("+ ${name}")`);
}
check('부품 5개 추가됨', (await page.locator('.asm-part').count()) === 5);
@@ -145,6 +146,24 @@ const SRC_SCAD = process.env.SRC_SCAD || '/srv/homeclaw/.smallclaw/users/papa/wo
const after = await (await fetch(`${base}/api/workshop/assembly/pz`)).json();
check('탭을 열면 오래된 그림 자동 재렌더', after.renderedAt > before.renderedAt && after.stale === false);
// ── 연결만 돼 있고 비어 있는 프로젝트(중단/수동)도 조립 탭에서 초안이 채워진다 + 빈 명세는 저장되지 않는다 ──
fs.mkdirSync(path.join(ws, 'workshop/pq/CAD'), { recursive: true });
fs.copyFileSync(SRC_SCAD, path.join(ws, 'workshop/pq/CAD/rig.scad'));
await fetch(`${base}/api/workshop/project/pq?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pq', name: '연결만 된 프로젝트', parts: [], phases: [], notes: '', assembly: { scad: 'workshop/pq/CAD/rig.scad', parts: [], steps: [] } }) });
await fetch(`${base}/api/workshop/project/pe?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pe', name: 'scad 없는 프로젝트', parts: [], phases: [], notes: '' }) });
await page.goto(`${base}/html/workshop-app.html`);
await page.waitForSelector('#project-select');
await page.selectOption('#project-select', 'pq');
await page.click('.rb-tab-btn[data-tab=assembly]');
await page.waitForSelector('.asm-part', { timeout: 60000 });
check('연결만 된 프로젝트: 초안 자동 생성', (await page.locator('.asm-part').count()) === 9);
await page.selectOption('#project-select', 'pe');
await page.waitForTimeout(1500);
await page.click('.rb-tab-btn[data-tab=assembly]');
await page.waitForTimeout(1500);
const pe = (await (await fetch(`${base}/api/workshop/data`)).json()).projects.find((p: any) => p.id === 'pe');
check('scad 없는 프로젝트: 조립 탭을 열어도 빈 명세가 저장되지 않음', pe.assembly === undefined, JSON.stringify(pe.assembly));
console.log(results.join('\n'));
console.log('페이지 JS 오류:', errors.length ? errors.join('; ') : '없음');
await browser.close(); server.close();
+36 -2
View File
@@ -131,6 +131,9 @@ describe('workshop_project 도구', () => {
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '출력' })).success, true);
assert.equal((await run(ws, { action: 'add_phase', new_phase_name: '조립' })).success, true);
}
// 중복 응답이 모델의 반복 호출을 유발하지 않도록 "이미 충족됨" 안내가 붙는다(09-25 LOOP WARN 실측)
const dup = await run(ws, { action: 'add_task', phase_name: '준비', task_text: '출력' });
assert.match(dup.stdout || '', /같은 호출을 반복하지 말고/);
const p = loadWorkshop(ws).projects[0];
assert.equal(p.parts.length, 1);
assert.equal(p.notes, '메모 한 줄');
@@ -147,7 +150,7 @@ describe('workshop-app.html 저장 로직', () => {
const to = html.findIndex(l => l.startsWith('// ── Tabs'));
const escHtmlLine = html.find(l => l.startsWith('function escHtml('))!;
function boot(server: any) {
function boot(server: any, putDelayMs = 0) {
const puts: any[] = [];
let clock = 1000;
const banner: any = { style: {}, innerHTML: '' };
@@ -158,6 +161,7 @@ describe('workshop-app.html 저장 로직', () => {
if (url === '/api/workshop/data') return { ok: true, status: 200, json: async () => JSON.parse(JSON.stringify(server)) };
const m = url.match(/^\/api\/workshop\/project\/([^?]+)(\?force=1)?$/);
if (m && opt.method === 'PUT') {
if (putDelayMs) await new Promise(r => setTimeout(r, putDelayMs)); // 네트워크 지연 흉내 — 겹치는 저장이 끼어들 틈
const p = JSON.parse(opt.body);
const i = server.projects.findIndex((x: any) => x.id === p.id);
const cur = server.projects[i];
@@ -179,7 +183,7 @@ describe('workshop-app.html 저장 로직', () => {
vm.createContext(ctx);
vm.runInContext([escHtmlLine, ...html.slice(start, start + 1), ...html.slice(from, to),
`this.api={loadWorkshopData,softRefreshWorkshop,saveProject,resolveConflict,setNotes:(v)=>{notes=v;scheduleSave();},setBudget:(v)=>{budget=v;scheduleSave();},
get conflict(){return conflict},get local(){return {notes,budget,parts}},escAttr};`].join('\n'), ctx);
saveProject,get conflict(){return conflict},get local(){return {notes,budget,parts}},escAttr};`].join('\n'), ctx);
return { api: ctx.api, puts, banner, tick: () => ++clock };
}
const mkServer = () => ({ projects: [proj('p1', 'A')], activeProjectId: 'p1' });
@@ -259,6 +263,36 @@ describe('workshop-app.html 저장 로직', () => {
assert.equal(server.projects[0].notes, '서버쪽');
});
test('저장이 동시에 두 번 불려도(자동 흐름의 flush + debounce 타이머) 가짜 충돌이 생기지 않는다', async () => {
const server: any = mkServer();
const { api, puts } = boot(server, 30);
await api.loadWorkshopData();
api.setNotes('첫 편집');
// debounce 타이머 저장과 flushNow(=saveProject 직접 호출)가 겹치는 상황
const [a, b] = [api.saveProject(), api.saveProject()];
await Promise.all([a, b]);
assert.equal(api.conflict, null, '자기 자신의 저장과 충돌로 오판');
assert.equal(server.projects[0].notes, '첫 편집');
assert.equal(puts.length, 1, '두 번째 저장은 무변경이라 PUT하지 않아야 함');
api.setNotes('두 번째 편집');
await Promise.all([api.saveProject(), api.saveProject(), api.saveProject()]);
assert.equal(api.conflict, null);
assert.equal(server.projects[0].notes, '두 번째 편집');
});
test('409여도 서버 내용이 내 내용과 같으면(내 이전 저장의 버전) 충돌 배너를 띄우지 않는다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setNotes('같은 내용');
await new Promise(r => setTimeout(r, 900)); // 정상 저장(서버 updatedAt 갱신)
server.projects[0].updatedAt = 9999; // 버전만 어긋남(내용은 동일)
api.setNotes('같은 내용 '); // 클라가 다시 dirty가 되도록 잠깐 바꿨다가
api.setNotes('같은 내용'); // 원래 내용으로 복귀
await api.saveProject();
assert.equal(api.conflict, null);
});
test('폴링 갱신: 저장 대기/충돌 중엔 서버 값을 안 끌어오고, 조용할 땐 반영한다', async () => {
const server: any = mkServer();
const { api } = boot(server);
+26 -2
View File
@@ -564,7 +564,8 @@ function syncLocalToProject(){
proj.notes=notes;
proj.description=overviewDesc;
proj.budget=budget;
if(assembly)proj.assembly=assembly;else delete proj.assembly;
// 빈 조립 명세(경로도 부품도 단계도 없음)는 저장하지 않는다 — 조립 탭을 열기만 해도 프로젝트에 빈 명세가 남던 문제
if(assembly&&(assembly.scad||assembly.parts.length||assembly.steps.length))proj.assembly=assembly;else delete proj.assembly;
if(savedJson.get(proj.id)!==JSON.stringify(proj))dirtyProjects.add(proj.id);
}
@@ -793,7 +794,17 @@ async function softRefreshWorkshop(opts){
}catch{}
}
async function saveProject(){
// 저장은 반드시 한 번에 하나씩(직렬화) — 자동 흐름(초안→렌더의 flushNow)과 debounce 타이머가 동시에 saveProject를
// 부르면 둘 다 같은 기준 버전(updatedAt)으로 PUT해서, 두 번째가 "첫 번째 저장이 만든 새 버전"과 어긋나 가짜 충돌(409)로
// 잡혔다(09-25 "렌더 실패: 저장 충돌을 먼저 해결하세요" 신고). 큐에 태우면 두 번째는 갱신된 버전/스냅샷을 보고 무변경이면 PUT을 건너뛴다.
let saveQueue=Promise.resolve();
function saveProject(){
saveDebounce=null;
const run=saveQueue.then(_saveProject,_saveProject);
saveQueue=run.catch(()=>{});
return run;
}
async function _saveProject(){
saveDebounce=null;
syncLocalToProject();
const ids=[...dirtyProjects];
@@ -810,6 +821,13 @@ async function saveProject(){
if(r.status===409){
// 낙관적 락: 내가 본 버전 이후 다른 곳(채팅 도구/다른 탭)이 이 프로젝트를 고쳤다 — 조용히 덮지 않는다.
const d=await r.json().catch(()=>({}));
// 단, 서버 내용이 내 내용과 똑같으면(내 이전 저장이 만든 버전) 충돌이 아니다 — 버전만 받아들이고 넘어간다.
const strip=x=>JSON.stringify({...x,updatedAt:undefined});
if(d.server&&strip(d.server)===strip(p)){
p.updatedAt=d.server.updatedAt;
savedJson.set(id,JSON.stringify(p));
continue;
}
dirtyProjects.add(id);
showConflict(id,d.server);
continue;
@@ -2079,6 +2097,12 @@ async function asmAutoLink(){
if(!assembly.parts.length&&await asmDraft(true))await asmRender(true);
return;
}
// 연결만 돼 있고 부품/단계가 하나도 없으면(자동 연결 직후 중단됐거나 수동으로 경로만 넣은 경우) 초안을 한 번 시도
if(a.scad&&!a.parts.length&&!a.steps.length&&asmAutoDone!==asmProjectId+':draft'){
asmAutoDone=asmProjectId+':draft';
if(await asmDraft(true))await asmRender(true);
return;
}
if(asmInfo&&asmInfo.stale&&a.autoRender!==false&&a.parts.length&&a.steps.length&&asmAutoDone!==asmProjectId+':stale'){
asmAutoDone=asmProjectId+':stale'; // 실패해도 이 화면에서 무한 재시도하지 않는다
await asmRender(true);