diff --git a/docs/workbench-history.md b/docs/workbench-history.md index a00092e..e574406 100644 --- a/docs/workbench-history.md +++ b/docs/workbench-history.md @@ -108,3 +108,22 @@ Starting a new verification or plan request revokes the previous plan and its co Artifact checks in the workbench retain the same bounded record-position evidence as offline HTML reports. Expand **Locate affected CSV records** to inspect the expected and delivered CSV record positions, then reopen the saved report later without rerunning the check. Positions start at 1, exclude the header and count quoted multiline records as one record; they are not physical text-line numbers. Only positions and declared column metadata are retained. Record identities and cell values are omitted, and the report names incomplete or truncated evidence. Machine JSON stays unchanged by default; use `--include-row-evidence` when a separate human review needs these bounded positions. + + +## Apply completion and uncertain batch output + +Every confirmed apply attempt consumes the current plan and its actionable issue +authority, including a stale-file failure. The old report remains historical +evidence. A fresh verification is required before another plan can be generated. +The page labels applied-but-not-rechecked and unconfirmed outcomes separately. + +If a batch run fails or is cancelled, the page retains the exact attempted output +path and offers an explicit read-only integrity check. This handles cancellation +after the CLI has already published its immutable packet. Missing files produce +an explicit error; the operation is not rerun and no existing packet is replaced. +Opening a packet verifies saved evidence only; it does not recheck current inputs +or authenticate its author. + +Editing a directory or contract invalidates the displayed evidence on input, +before blur can collapse the report under a click. Consent stays disabled while +a plan is loading, and clearing an already empty report does not remount its frame. diff --git a/src/workbench/page.ts b/src/workbench/page.ts index aeee7ba..abf39c7 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -3,7 +3,7 @@ export function workbenchPage(nonce: string): string { return `SkillSync 核验台

SkillSync

本机核验台

选择要检查的目录或交付,核对证据后再决定下一步。

- +

核验一个目录

选择目录与范围 → 核验 → 查看结果

选择要完成的任务

尚未核验

@@ -15,7 +15,7 @@ export const workbenchStyle = `:root{font:16px/1.6 system-ui;color:#19343c;backg export const workbenchScript = `"use strict"; const $ = id => document.getElementById(id); const nonce = document.querySelector('meta[name="client"]').content; -let busy = false, planId = null, selected = null, baselineId = null, historyId = null, historyOffset = 0, historyTotal = 0, historyLoading = false, historyRefreshQueued = false, renderedHistory = null, batchReady=false, summaryCanPlan=false; +let busy = false, planId = null, selected = null, baselineId = null, historyId = null, historyOffset = 0, historyTotal = 0, historyLoading = false, historyRefreshQueued = false, renderedHistory = null, batchReady=false, summaryCanPlan=false, recoveryPath=null; // Switching tasks hides existing controls; it never replaces form nodes or submits them. let activeTask='single', resultOwner='single', resultAvailable=false, fixAvailable=false; function syncWorkspace(){ @@ -42,12 +42,13 @@ function mountReport(){ frame.srcdoc=reportHtml;$('report').replaceWith(frame); } function showReport(html){reportHtml=html.replaceAll('href="#','href="about:srcdoc#');if(!$('result').hidden)mountReport();} -function clearReport(){summaryCanPlan=false;reportHtml='';$('report').srcdoc='';} +function clearReport(){summaryCanPlan=false;if(!reportHtml)return;reportHtml='';$('report').srcdoc='';} function status(text){ $('status').textContent = text; } -function lock(value, applying = false){ busy=value; ['verify','plan','result-recheck','result-details','mode-skill','mode-artifacts','reverify','choose','choose-contract','root','contract','kind','delivery','history-refresh','batch-plan','batch-preview','batch-output','batch-previous','batch-retry','batch-package','batch-digest','batch-open','choose-batch-plan','choose-batch-package'].forEach(id => $(id).disabled=value); $('batch-run').disabled=value||!batchReady; document.querySelectorAll('.history-open').forEach(button=>button.disabled=value); $('history-prev').disabled=value||historyOffset===0; $('history-next').disabled=value||historyOffset+12>=historyTotal; $('export-summary').disabled=value||!historyId; $('cancel').disabled=!value || applying; $('apply').disabled=value || !$('confirm').checked || !planId; $('plan').disabled=value||!selected||!summaryCanPlan; } -function clearPlan(){planId=null;$('confirm').checked=false;setFix(false);$('changes').textContent='';$('apply').disabled=true;} -function invalidate(){ fieldError('root','');fieldError('contract','');selected=null; planId=null; baselineId=null; historyId=null; $('comparison').textContent=''; $('export-summary').disabled=true; hideResult(); setFix(false); $('confirm').checked=false; clearReport(); status('目录或模式已更改,请重新核验'); } -['root','contract','kind','delivery'].forEach(id => $(id).addEventListener('change', () => {invalidate(); syncMode();})); +function lock(value, applying = false){ busy=value; ['verify','plan','result-recheck','result-details','mode-skill','mode-artifacts','reverify','choose','choose-contract','root','contract','kind','delivery','history-refresh','batch-plan','batch-preview','batch-recover','batch-output','batch-previous','batch-retry','batch-package','batch-digest','batch-open','choose-batch-plan','choose-batch-package'].forEach(id => $(id).disabled=value); $('batch-run').disabled=value||!batchReady; document.querySelectorAll('.history-open').forEach(button=>button.disabled=value); $('history-prev').disabled=value||historyOffset===0; $('history-next').disabled=value||historyOffset+12>=historyTotal; $('export-summary').disabled=value||!historyId; $('cancel').disabled=!value || applying; $('confirm').disabled=value||!planId; $('apply').disabled=value || !$('confirm').checked || !planId; $('plan').disabled=value||!selected||!summaryCanPlan; } +function clearPlan(){$('fix-title').textContent='审阅修复计划';planId=null;$('confirm').checked=false;setFix(false);$('changes').textContent='';$('apply').disabled=true;} +function invalidate(){ $('plan').disabled=true;$('plan').hidden=true;fieldError('root','');fieldError('contract','');selected=null; planId=null; baselineId=null; historyId=null; $('comparison').textContent=''; $('export-summary').disabled=true; hideResult(); setFix(false); $('confirm').checked=false; clearReport(); status('目录或模式已更改,请重新核验'); } +['root','contract'].forEach(id=>$(id).addEventListener('input',()=>{invalidate();syncMode();})); +['kind','delivery'].forEach(id=>$(id).addEventListener('change',()=>{invalidate();syncMode();})); function syncMode(){const artifacts=$('kind').value==='artifacts';$('mode-skill').checked=!artifacts;$('mode-artifacts').checked=artifacts;$('contract-fields').hidden=!artifacts;$('root-label').textContent=artifacts?'交付目录':'Skill 根目录或包含 Skills 的目录';$('root').placeholder=artifacts?'/project/artifacts…':'/project/my-skill…';$('mode-help').textContent=artifacts?'需要交付目录和验收清单(YAML / JSON)。如使用 SkillTape 交付,请勾选 delivery.json 模式。':'首次使用建议选择直接包含 SKILL.md 的目录。这里只检查文件,不运行 Skill。';} for(const [id,kind] of [['mode-skill','skill'],['mode-artifacts','artifacts']])$(id).onchange=()=>{if($(id).checked){$('kind').value=kind;invalidate();syncMode();}}; function fieldError(id,message){$(id+'-error').textContent=message;$(id).setAttribute('aria-invalid',String(Boolean(message)));if(message)$(id).focus();} @@ -65,7 +66,14 @@ $('verify').onclick=()=>verify(); $('reverify').onclick=()=>verify(true); $('cancel').onclick=async()=>{try{await api('cancel');status('已请求取消,等待核验器退出…');}catch(e){status(e.message);}}; $('plan').onclick=async()=>{if(!selected||!summaryCanPlan)return;clearPlan();lock(true);try{const data=await api('plan');planId=data.id; $('changes').textContent=data.description; setFix(true); $('confirm').checked=false; $('receipt').textContent='确认应用后才会修改文件。'; status('计划已生成,请审阅'); await loadHistory(true);}catch(e){status(e.message);}finally{lock(false);}}; $('confirm').onchange=()=>{$('apply').disabled=busy||!$('confirm').checked||!planId;}; -$('apply').onclick=async()=>{lock(true,true);try{const data=await api('apply',{id:planId,confirm:$('confirm').checked}); planId=null; $('confirm').checked=false; $('receipt').textContent=data.message; status('已备份并应用,请重新核验'); await loadHistory(true);}catch(e){planId=null;$('confirm').checked=false;status(e.message+';请重新核验当前目录');}finally{lock(false);}}; +$('apply').onclick=async()=>{ + if(busy||!planId||!$('confirm').checked)return; + const id=planId;planId=null;summaryCanPlan=false;$('plan').hidden=true;$('confirm').checked=false;lock(true,true);$('fix-title').textContent='应用结果与复查'; + showTaskSummary({title:'正在应用已审阅的计划',description:'完成后仍须重新核验当前目录。',counts:[],canPlan:false}); + try{const data=await api('apply',{id,confirm:true});$('receipt').textContent=data.message;showTaskSummary({title:'已应用并备份,待重新核验',description:'刚才的报告保留为历史依据。请重新核验当前文件后再决定下一步。',counts:[],canPlan:false});status('已备份并应用,请重新核验');await loadHistory(true);} + catch(e){$('receipt').textContent=e.message;showTaskSummary({title:'应用结果待核对',description:'原计划已失效。请查看保存的回执或重新核验当前目录,不要重复应用旧计划。',counts:[],canPlan:false});status(e.message+';请重新核验当前目录');} + finally{lock(false);} +}; function showComparison(value){ $('comparison').textContent=!value?'此次结果可作为后续复查基线。':value.status==='compared'?'与基线相比:新增 '+value.added+' · 仍然存在 '+value.ongoing+' · 证据变化 '+(value.changed||0)+' · 本次不再出现 '+value.resolved:'基线无法比较:范围已变化或证据不完整。'; } async function loadHistory(newest=false){ if(historyLoading){if(newest)historyRefreshQueued=true;return;}historyLoading=true; $('history-prev').disabled=true;$('history-next').disabled=true;$('history-refresh').disabled=true; @@ -86,12 +94,18 @@ $('history-refresh').onclick=()=>loadHistory(true); $('history-prev').onclick=()=>{if(historyLoading)return;historyOffset=Math.max(0,historyOffset-12);loadHistory();}; $('history-next').onclick=()=>{if(historyLoading)return;historyOffset+=12;loadHistory();}; $('export-summary').onclick=async()=>{lock(true,true);try{const data=await api('history/export',{id:historyId});const url=URL.createObjectURL(new Blob([data.html],{type:'text/html;charset=utf-8'})),link=document.createElement('a');link.href=url;link.download='skillsync-comparison.html';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){status(e.message);}finally{lock(false);}}; -function clearBatchContext(){$('batch-receipt').textContent='';clearPlan();selected=null;historyId=null;baselineId=null;$('plan').hidden=true;setFix(false);$('export-summary').disabled=true;$('comparison').textContent='';hideResult();clearReport();} +function clearBatchContext(){recoveryPath=null;$('batch-recovery').hidden=true;$('batch-receipt').textContent='';clearPlan();selected=null;historyId=null;baselineId=null;$('plan').hidden=true;setFix(false);$('export-summary').disabled=true;$('comparison').textContent='';hideResult();clearReport();} $('batch-plan').addEventListener('input',()=>{batchReady=false;$('batch-run').disabled=true;$('batch-info').textContent='计划已改变,请重新预检。';}); for(const [button,field]of [['choose-batch-plan','batch-plan'],['choose-batch-package','batch-package']])$(button).onclick=async()=>{lock(true,true);try{const data=await api('choose',{file:true});$(field).value=data.path;if(field==='batch-plan'){batchReady=false;$('batch-info').textContent='请选择预检。';}}catch(e){status(e.message);}finally{lock(false);}}; $('batch-preview').onclick=async()=>{clearBatchContext();batchReady=false;lock(true);status('正在预检多批次计划…');try{const data=await api('batch/preview',{path:$('batch-plan').value});batchReady=true;$('batch-output').value=data.suggestedOutput;$('batch-info').textContent=data.batches.map(item=>item.id+' · '+item.files+' 文件 / '+item.bytes+' 字节'+(item.available?'':' · 来源不可用')).join(';');status('计划预检完成,尚未生成复核包。');}catch(e){status(e.message);}finally{lock(false);}}; function showBatch(data){$('task-summary').hidden=true;$('plan').hidden=true;setFix(false);const c=data.summary.counts;$('batch-receipt').textContent='复核包完整性已验证 · 通过 '+c.passed+' · 失败 '+c.failed+' · 不完整 '+c.incomplete+' · SHA-256 '+data.package_sha256;$('batch-digest').value=data.package_sha256;status('已读取有界验收证据;原始数据未在打开时重新核验,作者未认证。');revealResult('batch');showReport(data.html);} -$('batch-run').onclick=async()=>{clearBatchContext();batchReady=false;lock(true);status('正在逐批核验并生成新复核包…');try{const data=await api('batch/run',{path:$('batch-plan').value,output:$('batch-output').value,previous:$('batch-previous').value,retryFailed:$('batch-retry').checked});$('batch-package').value=data.packagePath;showBatch(data);}catch(e){status(e.message);}finally{lock(false);}}; +function offerBatchRecovery(path,message){ + recoveryPath=typeof path==='string'&&path.trim()&&path.length<=4096&&!/[\u0000-\u001f]/.test(path)?path:null; + $('batch-recovery').hidden=!recoveryPath;if(!recoveryPath)return; + $('batch-package').value=recoveryPath;$('batch-digest').value='';$('batch-recovery-state').textContent=message+' 此次输出:'+recoveryPath+'。先只读检查可能已生成的包;文件不存在时会明确说明,原输出不会被覆盖。'; +} +$('batch-run').onclick=async()=>{const output=$('batch-output').value;clearBatchContext();batchReady=false;lock(true);status('正在逐批核验并生成新复核包…');try{const data=await api('batch/run',{path:$('batch-plan').value,output,previous:$('batch-previous').value,retryFailed:$('batch-retry').checked});$('batch-package').value=data.packagePath;showBatch(data);}catch(e){status(e.message);offerBatchRecovery(output,e.message);}finally{lock(false);}}; +$('batch-recover').onclick=async()=>{if(busy||!recoveryPath)return;const path=recoveryPath;lock(true);status('正在只读核对此次输出…');try{showBatch(await api('batch/open',{path,expectSha256:''}));$('batch-package').value=path;recoveryPath=null;$('batch-recovery').hidden=true;}catch(e){$('batch-recovery-state').textContent=e.message+';路径保留,可稍后再检查。';status(e.message);}finally{lock(false);}}; $('batch-open').onclick=async()=>{clearBatchContext();batchReady=false;lock(true);status('正在复核已保存的包…');try{showBatch(await api('batch/open',{path:$('batch-package').value,expectSha256:$('batch-digest').value}));}catch(e){status(e.message);}finally{lock(false);}}; syncWorkspace(); loadHistory(); diff --git a/src/workbench/server.ts b/src/workbench/server.ts index 21040ee..9681286 100644 --- a/src/workbench/server.ts +++ b/src/workbench/server.ts @@ -235,7 +235,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; } if (req.url === "/api/apply") { if (!plan || input.id !== plan.id || input.confirm !== true) throw new WorkbenchStateError("请审阅当前计划并明确确认。"); - const current = plan; plan = undefined; + const current = plan; plan = undefined; actionable = []; if (!current.value.changes.length || current.value.changes.length > 256 || current.value.changes.some(c => c.safety !== "safe")) throw new WorkbenchStateError("此计划包含需人工审阅的变更,请使用 CLI 逐项处理。"); if (current.snapshot !== (await scanStagedWorkspace(selected.path)).digest) throw new WorkbenchStateError("文件已变化,当前计划不能应用。请重新核验。"); await history.read(current.historyId); diff --git a/tests/workbench/page.test.ts b/tests/workbench/page.test.ts index 4a6aeaa..4650aeb 100644 --- a/tests/workbench/page.test.ts +++ b/tests/workbench/page.test.ts @@ -12,7 +12,8 @@ class Element { focused = false; setAttribute(name: string, value: string) { this.attributes.set(name,value); } focus() { this.focused = true; } - addEventListener() {} + events=new Mapvoid>(); + addEventListener(name:string,handler:()=>void) {this.events.set(name,handler);} replaceWith(element: Element) { Object.assign(this, element); } replaceChildren() { this.children = []; } append(...elements: Element[]) { this.children.push(...elements); } @@ -55,6 +56,21 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de } describe("workbench interactions", () => { + it("invalidates prior evidence while typing, before blur can move the verification button",async()=>{ + const ui=await page();await ui.click("verify");expect(ui.get("result").hidden).toBe(false);const requests=ui.actions.length; + ui.get("root").value="/synthetic/another";ui.get("root").events.get("input")?.();expect(ui.get("result").hidden).toBe(true);expect(ui.get("plan").disabled).toBe(true);expect(ui.actions.length).toBe(requests); + }); + + it.each([false,true])("requires fresh verification after an apply attempt, including failure=%s",async failure=>{ + const ui=await page();await ui.click("verify");await ui.click("plan");ui.get("confirm").checked=true;ui.get("confirm").onchange?.();if(failure)ui.fail("apply","Files changed; result unconfirmed");await ui.click("apply"); + expect(ui.get("plan").hidden).toBe(true);expect(ui.get("plan").disabled).toBe(true);expect(ui.get("apply").disabled).toBe(true);const count=ui.actions.length;await ui.click("plan");expect(ui.actions.length).toBe(count); + await ui.click("result-recheck");expect(ui.get("plan").hidden).toBe(false);expect(ui.get("plan").disabled).toBe(false); + }); + it("keeps the attempted batch output for explicit read-only recovery without rerunning",async()=>{ + const ui=await page();ui.get("batch-output").value="/synthetic/review-packet.json";ui.fail("batch/run","核验已取消,先检查可能保留的输出");await ui.click("nav-batch");await ui.click("batch-run"); + expect(ui.get("batch-recovery").hidden).toBe(false);expect(ui.get("batch-package").value).toBe("/synthetic/review-packet.json");ui.fail("batch/open","输出尚不存在");await ui.click("batch-recover");expect(ui.get("batch-recovery-state").textContent).toContain("输出尚不存在");expect(ui.actions.filter(v=>v==="batch/run")).toHaveLength(1); + }); + it("explains missing task inputs before contacting a verifier", async () => { const ui=await page();ui.get("root").value=" ";await ui.click("verify"); expect(ui.get("root-error").textContent).toContain("Skill 目录");expect(ui.get("root").focused).toBe(true); diff --git a/tests/workbench/server.test.ts b/tests/workbench/server.test.ts index c7d62db..2121f3e 100644 --- a/tests/workbench/server.test.ts +++ b/tests/workbench/server.test.ts @@ -163,6 +163,7 @@ describe("real CLI graphical workbench", () => { const applied = await api("apply", {id: planned.data.id, confirm: true}); expect(applied.status).toBe(200); expect(applied.data.message).toContain("尚未重新核验"); expect(applied.data.message).toContain("备份"); expect((await stat(script)).mode & 0o777).toBe(0o755); expect(await readFile(script)).toEqual(original); + expect((await api("plan")).status).toBe(400); const after = await api("verify"); expect(after.status).toBe(200); expect(after.data.html).not.toContain("structure.invalid-script-mode"); expect((await api("apply", {id: planned.data.id, confirm: true})).status).toBe(400); expect((await stat(join(base, "state"))).mode & 0o777).toBe(0o700);