diff --git a/docs/workbench-history.md b/docs/workbench-history.md index 0e13546..a00092e 100644 --- a/docs/workbench-history.md +++ b/docs/workbench-history.md @@ -1,5 +1,19 @@ # Workbench history and rechecks +## Choose a task and continue from its result + +The single-directory page separates Skill inspection from artifact acceptance. +Each choice explains its directory and contract requirements. Empty inputs are +marked next to the field before a verification request is sent. After a run, a +summary above the sandboxed report explains its outcome and next step. Only a +complete current Skill result with actionable issues offers a plan; artifact +checks direct you to repair the original inputs and verify again. + +A historical verification is labelled read-only. Its **Recheck current files** +action returns to the single-directory task and performs a fresh selection and +verification. It never applies the stored plan. Multi-batch reports retain their +separate package summary and do not show a single-directory action summary. + The local graphical workbench keeps immutable index records beside reports, plans and receipts in its private state directory. Open **History** to inspect a previous verification and use it as a baseline. Click **Verify directory** to diff --git a/src/workbench/journey-summary.ts b/src/workbench/journey-summary.ts new file mode 100644 index 0000000..cdf3a26 --- /dev/null +++ b/src/workbench/journey-summary.ts @@ -0,0 +1,14 @@ +import type { VerificationReport } from '../cli/commands/verify.js'; +import type { ArtifactReport } from '../domain/artifact-report.js'; +export type JourneySummary={title:string;description:string;counts:Array<{label:string;count:number}>;canPlan:boolean;readOnly:boolean}; +export function journeySummary(kind:'skill'|'artifacts',report:VerificationReport|ArtifactReport,readOnly=false):JourneySummary{ + const incomplete=[2,4].includes(report.exitCode)||(kind==='artifacts'&&(report as ArtifactReport).reconciliations?.some(check=>check.status==='unknown')); + let counts:JourneySummary['counts'],actionable=0; + if(kind==='skill'){ + const value=report as VerificationReport;actionable=value.issues.filter(issue=>['fail','warn'].includes(issue.finding.status)).length; + counts=[{label:'阻断问题',count:value.summary.fail},{label:'建议核对',count:value.summary.warn},{label:'尚未确认',count:value.summary.unknown}]; + }else{const value=report as ArtifactReport;counts=[{label:'已读取文件',count:value.files.length},{label:'问题定位',count:value.findings.length},{label:'未完成对账',count:value.reconciliations?.filter(check=>check.status==='unknown').length||0}];} + const canPlan=!readOnly&&kind==='skill'&&!incomplete&&actionable>0; + return {title:readOnly?'历史证据 · 只读':incomplete?'本次检查尚未完整':report.exitCode===0?'所声明的检查通过':'存在需要处理的项目', + description:readOnly?'打开历史不会重新核验或恢复修复权限。可重新核验当前目录再决定下一步。':incomplete?'先查看报告中的输入、容量或未完成原因,修正后重新核验;当前结果不能作为完整通过。':kind==='artifacts'?'交付检查不提供自动修改。按报告修正原文件或清单,再明确重新检查;本报告会保留。':canPlan?'可先生成处理计划,区分安全修改与人工步骤;审阅并明确确认后才会写文件。':'可查看报告或保存摘要。继续修改文件后,请重新核验。',counts,canPlan,readOnly}; +} diff --git a/src/workbench/page.ts b/src/workbench/page.ts index 06d558d..aeee7ba 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -5,17 +5,17 @@ export function workbenchPage(nonce: string): string { -

核验一个目录

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

-

尚未核验

+

核验一个目录

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

选择要完成的任务

+

尚未核验

`; } -export const workbenchStyle = `:root{font:16px/1.6 system-ui;color:#19343c;background:#f3f6f6}*{box-sizing:border-box}main{max-width:1040px;margin:auto;padding:32px 20px}header{margin-bottom:28px}h1{margin:8px 0;font-size:28px;line-height:1.2}h3{font-size:17px}.product-name{font-weight:750;letter-spacing:-.02em;margin:0}.task-nav{display:flex;flex-wrap:wrap;gap:8px;padding-bottom:16px;border-bottom:1px solid #c8d8dc}.task-nav button[aria-pressed=true]{color:white;background:#235661;border-color:#235661}.primary{color:white;background:#235661;border-color:#235661}.task-steps{color:#496771;font-size:14px}.skip-link{position:absolute;top:-100px;padding:12px;background:white;color:#19343c}.skip-link:focus{top:8px;z-index:10}h2[tabindex],main{scroll-margin-top:16px}h2[tabindex]:focus{outline:3px solid #087a91;outline-offset:5px}hr{border:0;border-top:1px solid #c8d8dc;margin:28px 0}h2{font-size:20px}section{padding:20px;background:white;border:1px solid #c8d8dc;border-radius:8px;margin:20px 0}label{display:block;margin:12px 0 6px}input:not([type=checkbox]),select{font:inherit;min-height:44px;padding:8px;width:100%;border:1px solid #829fa7;border-radius:4px}input[type=checkbox]{width:24px;height:24px;vertical-align:middle;margin-right:8px}label:has(input[type=checkbox]){min-height:44px;display:flex;align-items:center}button{font:inherit;min-height:44px;padding:8px 18px;border:1px solid #6f939d;border-radius:4px;color:#19343c;background:#edf5f6;cursor:pointer}button:disabled{opacity:.55;cursor:default}.row{display:flex;gap:12px;align-items:center}.row input{flex:1;min-width:0}.row button{white-space:nowrap}.actions{flex-wrap:wrap;margin-top:18px}:focus-visible{outline:3px solid #087a91;outline-offset:3px}iframe{width:100%;height:580px;border:1px solid #c8d8dc;margin-bottom:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.7 ui-monospace,monospace}#history-list p,#batch-receipt,#batch-info{overflow-wrap:anywhere}footer{font-size:13px;color:#496771}footer p{margin:8px 0}summary{cursor:pointer;min-height:44px;padding:8px 0}.operation-status{display:flex;align-items:center;justify-content:space-between;gap:12px}.operation-status button{flex-shrink:0}.operation-status p{overflow-wrap:anywhere}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}@media(max-width:580px){.operation-status{align-items:flex-start;flex-direction:column}.row{flex-wrap:wrap}.row input{flex-basis:100%}main{padding:16px}section{padding:16px}}`; +export const workbenchStyle = `:root{font:16px/1.6 system-ui;color:#19343c;background:#f3f6f6}*{box-sizing:border-box}main{max-width:1040px;margin:auto;padding:32px 20px}header{margin-bottom:28px}h1{margin:8px 0;font-size:28px;line-height:1.2}h3{font-size:17px}.product-name{font-weight:750;letter-spacing:-.02em;margin:0}.task-nav{display:flex;flex-wrap:wrap;gap:8px;padding-bottom:16px;border-bottom:1px solid #c8d8dc}.task-nav button[aria-pressed=true]{color:white;background:#235661;border-color:#235661}.primary{color:white;background:#235661;border-color:#235661}.task-steps{color:#496771;font-size:14px}.skip-link{position:absolute;top:-100px;padding:12px;background:white;color:#19343c}.skip-link:focus{top:8px;z-index:10}h2[tabindex],main{scroll-margin-top:16px}h2[tabindex]:focus{outline:3px solid #087a91;outline-offset:5px}hr{border:0;border-top:1px solid #c8d8dc;margin:28px 0}h2{font-size:20px}section{padding:20px;background:white;border:1px solid #c8d8dc;border-radius:8px;margin:20px 0}label{display:block;margin:12px 0 6px}input:not([type=checkbox]),select{font:inherit;min-height:44px;padding:8px;width:100%;border:1px solid #829fa7;border-radius:4px}input[type=checkbox]{width:24px;height:24px;vertical-align:middle;margin-right:8px}label:has(input[type=checkbox]){min-height:44px;display:flex;align-items:center}button{font:inherit;min-height:44px;padding:8px 18px;border:1px solid #6f939d;border-radius:4px;color:#19343c;background:#edf5f6;cursor:pointer}button:disabled{opacity:.55;cursor:default}.row{display:flex;gap:12px;align-items:center}.row input{flex:1;min-width:0}.row button{white-space:nowrap}.actions{flex-wrap:wrap;margin-top:18px}:focus-visible{outline:3px solid #087a91;outline-offset:3px}iframe{width:100%;height:580px;border:1px solid #c8d8dc;margin-bottom:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.7 ui-monospace,monospace}#history-list p,#batch-receipt,#batch-info{overflow-wrap:anywhere}footer{font-size:13px;color:#496771}footer p{margin:8px 0}summary{cursor:pointer;min-height:44px;padding:8px 0}.operation-status{display:flex;align-items:center;justify-content:space-between;gap:12px}.operation-status button{flex-shrink:0}.operation-status p{overflow-wrap:anywhere}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}.selection-options{border:0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:12px}.selection-options label{display:flex;align-items:flex-start;gap:10px;border:1px solid #c8d8dc;border-radius:6px;padding:14px;margin:0}.selection-options label:has(input:checked){border-color:#235661;background:#edf5f6}.selection-options input[type=radio]{width:22px;min-height:22px;flex:0 0 22px}.selection-options small{display:block;color:#496771}.field-error{color:#9c2f35}.mode-help{background:#edf5f6;padding:12px}.result-summary{border-left:3px solid #318496;padding:4px 16px;margin:16px 0}.result-summary ul{display:flex;gap:18px;flex-wrap:wrap;padding-left:20px}.result-summary .actions{margin-bottom:16px}@media(max-width:580px){.selection-options{grid-template-columns:1fr}}@media(max-width:580px){.operation-status{align-items:flex-start;flex-direction:column}.row{flex-wrap:wrap}.row input{flex-basis:100%}main{padding:16px}section{padding:16px}}`; 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; +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; // 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,20 +42,28 @@ function mountReport(){ frame.srcdoc=reportHtml;$('report').replaceWith(frame); } function showReport(html){reportHtml=html.replaceAll('href="#','href="about:srcdoc#');if(!$('result').hidden)mountReport();} -function clearReport(){reportHtml='';$('report').srcdoc='';} +function clearReport(){summaryCanPlan=false;reportHtml='';$('report').srcdoc='';} function status(text){ $('status').textContent = text; } -function lock(value, applying = false){ busy=value; ['verify','plan','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; } +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(){ 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(); $('contract-fields').hidden=$('kind').value!=='artifacts';})); +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 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();} +function validateInputs(){fieldError('root','');fieldError('contract','');if(!$('root').value.trim()){fieldError('root',$('kind').value==='artifacts'?'请选择或填写交付目录。':'请选择或填写 Skill 目录。');return false;}if($('kind').value==='artifacts'&&!$('contract').value.trim()){fieldError('contract','交付验收需要一份 YAML 或 JSON 清单,请选择清单文件。');return false;}return true;} +function showTaskSummary(value){$('task-summary').hidden=false;summaryCanPlan=Boolean(value?.canPlan)&&!value?.readOnly;$('summary-title').textContent=value?.title||'核验结果';$('summary-next').textContent=value?.description||'查看完整报告后,再决定下一步。';$('summary-counts').replaceChildren();for(const item of (value?.counts||[])){const li=document.createElement('li');li.textContent=item.label+':'+item.count;$('summary-counts').append(li);}$('plan').hidden=!summaryCanPlan;$('plan').disabled=busy||!selected||!summaryCanPlan;} +$('result-details').onclick=()=>{$('report').scrollIntoView?.({block:'start',behavior:'auto'});$('report').focus();}; +$('result-recheck').onclick=()=>{if(busy)return;setTask('single');return verify(false);}; +syncMode(); for(const [button,field] of [['choose','root'],['choose-contract','contract']]) $(button).onclick=async()=>{ lock(true,true); status('请在系统窗口中选择;取消后也可直接填写完整路径。'); try { const data=await api('choose',{file:field==='contract'}); $(field).value=data.path; invalidate(); } catch(e){status(e.message);} finally{lock(false);} }; -async function verify(recheck=false){ clearPlan();hideResult();clearReport();selected=null;$('plan').hidden=true;lock(true); status(recheck?'正在重新核验…':'正在核验…'); try { +async function verify(recheck=false){ if(busy||!validateInputs())return;clearPlan();hideResult();clearReport();selected=null;$('plan').hidden=true;lock(true); status(recheck?'正在重新核验…':'正在核验…'); try { if(!recheck){ await api('select',{path:$('root').value,kind:$('kind').value,contract:$('contract').value,delivery:$('delivery').checked,...(baselineId?{baselineId}:{})}); planId=null; setFix(false); $('confirm').checked=false; } - const data=await api('verify'); selected=true; historyId=data.historyId; baselineId=data.baselineId; planId=null; $('confirm').checked=false; setFix(false); showComparison(data.comparison); await loadHistory(true); revealResult('single'); showReport(data.html); $('plan').hidden=$('kind').value!=='skill'; status((recheck?'重新核验完成':'核验完成')+' · '+data.status); if(recheck) $('receipt').textContent='最新核验:'+data.status; + const data=await api('verify'); selected=true; historyId=data.historyId; baselineId=data.baselineId; planId=null; $('confirm').checked=false; setFix(false); showComparison(data.comparison); await loadHistory(true); revealResult('single'); showReport(data.html); showTaskSummary(data.summary); status((recheck?'重新核验完成':'核验完成')+' · '+data.status); if(recheck) $('receipt').textContent='最新核验:'+data.status; }catch(e){status(e.message);}finally{lock(false);} } $('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)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);}}; +$('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);}}; function showComparison(value){ $('comparison').textContent=!value?'此次结果可作为后续复查基线。':value.status==='compared'?'与基线相比:新增 '+value.added+' · 仍然存在 '+value.ongoing+' · 证据变化 '+(value.changed||0)+' · 本次不再出现 '+value.resolved:'基线无法比较:范围已变化或证据不完整。'; } @@ -67,7 +75,7 @@ async function loadHistory(newest=false){ const signature=JSON.stringify(data.items); if(signature!==renderedHistory){ $('history-list').replaceChildren(); for(const item of data.items){const row=document.createElement('p'), label=document.createElement('span'), button=document.createElement('button'); label.textContent=new Date(item.createdAt).toLocaleString()+' · '+({verify:'核验',plan:'修复计划','apply-intent':'应用请求',apply:'应用回执'}[item.kind])+' · '+item.label+' '; button.type='button';button.className='history-open';button.textContent=item.kind==='verify'?'查看并设为基线':'查看'; button.disabled=busy; - button.onclick=async()=>{lock(true,true);try{const data=await api('history/open',{id:item.id}); selected=null;planId=null;baselineId=data.baselineId||null;historyId=data.id;$('root').value=data.selection.path;$('kind').value=data.selection.kind;$('contract').value=data.selection.contract||'';$('delivery').checked=data.selection.delivery;$('contract-fields').hidden=data.selection.kind!=='artifacts';$('confirm').checked=false;setFix(false);$('plan').hidden=true;revealResult('history');showReport(data.html);showComparison(data.comparison);status('正在查看历史记录;点击“核验目录”检查当前文件。');}catch(e){status(e.message);}finally{lock(false);}}; + button.onclick=async()=>{lock(true,true);try{const data=await api('history/open',{id:item.id}); selected=null;planId=null;baselineId=data.baselineId||null;historyId=data.id;$('root').value=data.selection.path;$('kind').value=data.selection.kind;$('contract').value=data.selection.contract||'';$('delivery').checked=data.selection.delivery;syncMode();$('confirm').checked=false;setFix(false);$('plan').hidden=true;revealResult('history');showReport(data.html);showComparison(data.comparison);showTaskSummary(data.summary);status('正在查看历史记录;点击“核验目录”检查当前文件。');}catch(e){status(e.message);}finally{lock(false);}}; row.append(label,button);$('history-list').append(row);} renderedHistory=signature; } $('history-info').textContent=data.total+' 条记录 · 保存文件 '+data.capacity.files+'/'+data.capacity.maxFiles+(data.unavailable?' · '+data.unavailable+' 条索引无法读取':''); @@ -82,7 +90,7 @@ function clearBatchContext(){$('batch-receipt').textContent='';clearPlan();selec $('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){$('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);} +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);}}; $('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(); diff --git a/src/workbench/server.ts b/src/workbench/server.ts index 5a05d0e..21040ee 100644 --- a/src/workbench/server.ts +++ b/src/workbench/server.ts @@ -13,6 +13,7 @@ import type { ArtifactReport } from "../domain/artifact-report.js"; import { renderVerificationHtml } from "../reporters/verification-html.js"; import { renderArtifactsHtml } from "../reporters/artifacts-html.js"; import { readWorkspaceFile, scanStagedWorkspace } from "../sandbox/workspace-tree.js"; +import { journeySummary } from "./journey-summary.js"; import { workbenchPage, workbenchScript, workbenchStyle } from "./page.js"; import { exportHistorySummary, MAX_OUTPUT, WorkbenchHistory, WorkbenchStateError, type HistoryComparison, type Selection } from "./history.js"; import { parseStrictJson } from "../domain/strict-json.js"; @@ -146,7 +147,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; if(record.kind==="verify")html=record.selection.kind==="skill"?renderVerificationHtml(parseStrictJson(output!) as VerificationReport):renderArtifactsHtml(parseArtifactReport(parseStrictJson(output!))); else if(record.kind==="plan")html=preview("历史计划,仅供查看。重新核验后才能生成新计划。\n\n"+describePlan(parseStrictJson(output!) as ActionPlan)); else html=preview(record.kind==="apply-intent"?"应用请求已记录。请查看关联回执;若结果未确认,先核验当前目录。":"应用回执已保留;仍需另行核验当前目录。"); - return send(res,200,{readOnly:true,id:record.id,kind:record.kind,selection:record.selection,baselineId:record.kind==="verify"?record.id:record.baselineId,comparison:record.comparison,html}); + return send(res,200,{readOnly:true,id:record.id,kind:record.kind,selection:record.selection,baselineId:record.kind==="verify"?record.id:record.baselineId,comparison:record.comparison,html,summary:record.kind==="verify"?journeySummary(record.selection.kind,record.selection.kind==="skill"?parseStrictJson(output!) as VerificationReport:parseArtifactReport(parseStrictJson(output!)),true):undefined}); } if (req.url === "/api/select") { batchPreview=undefined; @@ -213,7 +214,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; } const saved=await history.append({kind:"verify",selection:selected,snapshot,baselineId:baseline?.id,parentId:latestId,exitCode:report.exitCode,comparison},output); latestId=saved.id;baseline??={id:saved.id,report,digest:saved.dataSha256!}; - return send(res, 200, {html, historyId:saved.id, baselineId:baseline.id, comparison, status: report.exitCode === 0 ? "已声明检查通过" : "存在待处理项", exitCode: report.exitCode}); + return send(res, 200, {html, historyId:saved.id, baselineId:baseline.id, comparison, summary:journeySummary(selected.kind,report), status: report.exitCode === 0 ? "已声明检查通过" : "存在待处理项", exitCode: report.exitCode}); } if (selected.kind !== "skill") throw new WorkbenchStateError("交付核验不提供自动修复。"); if (req.url === "/api/plan") { diff --git a/tests/workbench/journey-summary.test.ts b/tests/workbench/journey-summary.test.ts new file mode 100644 index 0000000..f7f298c --- /dev/null +++ b/tests/workbench/journey-summary.test.ts @@ -0,0 +1,7 @@ +import {expect,it} from 'vitest';import {journeySummary} from '../../src/workbench/journey-summary'; +import type {VerificationReport} from '../../src/cli/commands/verify';import type {ArtifactReport} from '../../src/domain/artifact-report'; +const skill={exitCode:1,summary:{fail:2,warn:1,unknown:0},issues:[{finding:{status:'fail'}}]} as VerificationReport; +it('turns current Skill evidence into a plan next-step without claiming application',()=>{const result=journeySummary('skill',skill);expect(result.canPlan).toBe(true);expect(result.counts[0].count).toBe(2);expect(result.description).toContain('明确确认');}); +it('historical evidence never regains mutation authority',()=>expect(journeySummary('skill',skill,true)).toMatchObject({readOnly:true,canPlan:false})); +it('incomplete evidence never offers a repair plan',()=>{expect(journeySummary('skill',{...skill,exitCode:2}).canPlan).toBe(false);expect(journeySummary('skill',{...skill,exitCode:4}).title).toContain('尚未完整');}); +it('artifact outcomes direct users to their files instead of safe-fix promises',()=>{const result=journeySummary('artifacts',{exitCode:1,files:[{}],findings:[{}],reconciliations:[]} as ArtifactReport);expect(result.canPlan).toBe(false);expect(result.description).toContain('不提供自动修改');expect(result.counts[0].count).toBe(1);}); diff --git a/tests/workbench/page.test.ts b/tests/workbench/page.test.ts index 400bf55..4a6aeaa 100644 --- a/tests/workbench/page.test.ts +++ b/tests/workbench/page.test.ts @@ -22,13 +22,13 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de const elements = new Map(); const get = (id: string) => { if (!elements.has(id)) elements.set(id, new Element()); return elements.get(id)!; }; get("kind").value = "skill"; get("root").value = "/synthetic/review"; - const offsets: number[] = []; + const offsets: number[] = [], actions: string[] = []; const rejected = new Map(); let releaseHistory!: () => void, historyCalls = 0; const initialHistory = new Promise(resolve => { releaseHistory = resolve; }); const entries = Array.from({length: 14}, (_, i) => ({id: String(i), kind: "verify", label: `record ${i}`, createdAt: "2026-01-01T00:00:00Z"})); const fetch = async (url: string, options: {body: string}) => { - const action = url.slice("/api/".length), input = JSON.parse(options.body); + const action = url.slice("/api/".length), input = JSON.parse(options.body); actions.push(action); if (rejected.has(action)) return {ok: false, status: 400, json: async () => ({error: rejected.get(action)})}; const firstHistory = action === "history" && historyCalls++ === 0; const savedHistory = {total: entries.length, items: entries.slice(input.offset, input.offset + 12), capacity: {files: entries.length * 2, maxFiles: 120}}; @@ -42,18 +42,37 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de } if (action === "verify") { entries.unshift({id: "new", kind: "verify", label: "new record", createdAt: "2026-01-02T00:00:00Z"}); - return {historyId: "new", baselineId: "new", html: 'Scope

Report

', status: "检查完成"}; + return {historyId: "new", baselineId: "new", html: 'Scope

Report

', status: "检查完成",summary:{title:"存在待处理项",description:"生成计划前先审阅",counts:[{label:"阻断问题",count:1}],canPlan:true,readOnly:false}}; } + if (action === "history/open") return {id: input.id, selection: {path:"/synthetic/review",kind:"skill",delivery:false},summary:{title:"历史证据 · 只读",description:"请重新核验当前目录",counts:[],canPlan:false,readOnly:true},html:"

Stored report

"}; if (action === "plan") return {id: "synthetic-plan", description: "Review this synthetic change"}; return {}; }}; }; runInNewContext(workbenchScript, {document: {getElementById: get, querySelector: () => get("meta"), querySelectorAll: () => [], createElement: () => new Element()}, fetch, URL, Blob, setTimeout}); await new Promise(resolve => setImmediate(resolve)); - return {get, offsets, fail(action: string, message: string) { rejected.set(action, message); }, async finishInitialHistory() { releaseHistory(); await new Promise(resolve => setImmediate(resolve)); }, async click(id: string) { await get(id).onclick?.(); await new Promise(resolve => setImmediate(resolve)); }}; + return {get, offsets, actions, fail(action: string, message: string) { rejected.set(action, message); }, async finishInitialHistory() { releaseHistory(); await new Promise(resolve => setImmediate(resolve)); }, async click(id: string) { await get(id).onclick?.(); await new Promise(resolve => setImmediate(resolve)); }}; } describe("workbench interactions", () => { + 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); + expect(ui.actions).not.toContain("select");expect(ui.actions).not.toContain("verify"); + ui.get("root").value="/synthetic/delivery";ui.get("mode-artifacts").checked=true;ui.get("mode-artifacts").onchange?.(); + expect(ui.get("contract-fields").hidden).toBe(false);expect(ui.get("root-label").textContent).toBe("交付目录"); + await ui.click("verify");expect(ui.get("contract-error").textContent).toContain("清单");expect(ui.get("contract").focused).toBe(true); + expect(ui.actions).not.toContain("select"); + }); + it("keeps a historical summary read-only and explicitly starts a fresh scoped verification", async () => { + const ui=await page();await ui.click("verify");await ui.click("nav-history"); + await ui.get("history-list").children[0].children[1].onclick?.(); + expect(ui.get("summary-title").textContent).toContain("只读");expect(ui.get("plan").hidden).toBe(true);expect(ui.get("plan").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("task-single").hidden).toBe(false);expect(ui.get("result").hidden).toBe(false); + expect(ui.actions.slice(count)).toEqual(["select","verify","history"]);expect(ui.get("summary-title").textContent).toBe("存在待处理项"); + }); + it("switches task surfaces without submitting or losing entered paths and approved-plan state", async () => { const ui = await page(); await ui.click("verify"); await ui.click("plan"); ui.get("root").value="/synthetic/kept"; ui.get("batch-plan").value="/synthetic/plan.json";