@feng0531/dsh-code-review 0.82.3 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/RELEASE-NOTES.md +6 -6
- package/package.json +1 -1
- package/src/background-review.mjs +3 -2
- package/src/job-progress.mjs +2 -0
- package/src/review-jobs.mjs +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
|
|
4
4
|
|
|
5
|
+
## 0.83.0 — 后台等待与真实进度
|
|
6
|
+
|
|
7
|
+
新增code_review_wait等待变化/终态,任务结果明确nextAction,避免只报任务启动就结束请求。进度以批准清单实际处理数计算,未知分母不伪造百分比;终态返回统一报告,预览确认不跳过。
|
|
8
|
+
|
|
5
9
|
## 0.82.3 — 综合失败隔离与一次性最终报告
|
|
6
10
|
|
|
7
11
|
保留主审和窗口成果,综合失败独立披露并保持整体partial。新增两次JSON尝试的脱敏诊断;全项目只渲染一份去重后的最终报告,不逐批拼接。
|
package/README.md
CHANGED
|
@@ -211,7 +211,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
|
|
|
211
211
|
1. 调用code_review_start,phase设为preview,previewArgs填原预览参数(例如reviewMode=project)。
|
|
212
212
|
2. 用code_review_status和jobId查询;完成后读取resultSummary中的预览范围、模型和previewId。
|
|
213
213
|
3. 展示预览,确认后调用code_review_start,phase设为execute,传previewId与confirmed=true。
|
|
214
|
-
4.
|
|
214
|
+
4. 持续调用code_review_wait等待进度变化或终态,不因启动返回任务ID就结束用户请求。可展示阶段与“处理数/总数”,不分批汇报问题;抓取阶段分母未知则不显示虚假百分比。等待返回仍running就继续等待,不重新启动。任务结束后取回并统一交付一份最终报告。查询审查任务;offset/limit分页取回检查点、文件状态和发现;单个检查点内的大清单用checkpointIndex指定其零基索引,再用offset/limit取回内部全部条目。可调用code_review_cancel取消。
|
|
215
215
|
|
|
216
216
|
报告保存在宿主profile的data/dsh-code-review/jobs目录;没有profile环境时使用用户主目录.dsh下的数据目录,不写入业务仓库。内容可能含源码片段,应按源码敏感级别保护。本次启动调用结束不自动取消后台任务;明确取消、停用插件或宿主退出会停止它。
|
|
217
217
|
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# v0.
|
|
1
|
+
# v0.83.0 发布说明
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 审查任务等待与进度
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
新增code_review_wait:等待后台任务进度变化或终态,不把返回任务ID当成审核完成。工具描述和运行结果引导Agent保持请求,预览完成时展示范围等待确认;已确认执行则等待完成后统一交付一份报告。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
状态返回真实阶段、elapsedMs、文件处理数/总数、主审完成/失败/阻断数量、waitRevision及nextAction。抓取阶段没有批准分母时percent为null;处理百分比不是通过率,也不证明综合完成。wait每次最多等待25秒,只结束该次等待,不取消后台任务;继续用同一jobId等待,不能重新启动重复审核。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
终态wait直接返回已保存的统一报告,nested report.status可能仍partial,不能把任务completed解释为通过。用户/宿主取消、任务归属、预览确认保持不变。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
本功能提供Agent持续等待的工具协议和可见进度,不修改DSH核心,也不能强制所有模型绝不提前结束;实际应用应按nextAction持续调用,不能用提示文本冒充宿主主动消息唤醒。用法见 [README](README.md)。
|
package/package.json
CHANGED
|
@@ -6,9 +6,10 @@ export function registerBackgroundReview(ctx,service,{directory}={}){
|
|
|
6
6
|
const root=directory??path.join(process.env.DSH_PROFILE_DIR??path.join(os.homedir(),'.dsh'),'data','dsh-code-review','jobs');
|
|
7
7
|
const jobs=createReviewJobs({directory:root});
|
|
8
8
|
const output={schema:{type:'object',additionalProperties:false,properties:{json:{type:'string'},markdown:{type:'string'}},required:['json','markdown']},render:(_a,v)=>[{type:'text',text:v.markdown||v.json}]};
|
|
9
|
-
const respond=value=>({json:JSON.stringify(value),markdown
|
|
10
|
-
ctx.tools.register({name:'code_review_start',description:'Start a durable background preview or confirmed review. Returns a job ID immediately. Preview phase never sends source. For execute supply the approved previewId and confirmed=true. Use code_review_status to collect progress/results; code_review_cancel to stop. Does not automatically resume after host restart or grant new source approval.',parameters:{type:'object',additionalProperties:false,properties:{phase:{type:'string',enum:['preview','execute']},previewArgs:{type:'object',description:'Same parameters as code_review_preview; do not include execute fields.'},previewId:{type:'string'},confirmed:{type:'boolean'}},required:['phase']},output,async execute(args,exec){if(!args||Object.keys(args).some(k=>!['phase','previewArgs','previewId','confirmed'].includes(k))||!['preview','execute'].includes(args.phase))throw new Error('Invalid background phase');if(args.phase==='execute'&&(args.confirmed!==true||typeof args.previewId!=='string'||args.previewArgs!==undefined))throw new Error('Confirmed preview required');if(args.phase==='preview'&&(args.previewId!==undefined||args.confirmed!==undefined))throw new Error('Preview cannot confirm execution');return respond(await jobs.start(exec,async({signal,checkpoint})=>{const original=exec.agent,sessionId=original.session.header.id??original.session.id,detached={...exec,agent:original,signal,reviewCheckpoint:async value=>{const active=exec.agent;if(active!==original||(active.session.header.id??active.session.id)!==sessionId){throw new Error('Background Agent/session changed');}await checkpoint(value);}};await checkpoint({stage:args.phase==='preview'?'capturing-preview':'revalidating-approved-source'});return args.phase==='preview'?service.preview(args.previewArgs??{},detached):service.execute({previewId:args.previewId,confirmed:true},detached);}));}});
|
|
9
|
+
const respond=value=>{const p=value.progress;const markdown=value.finalMarkdown??(p?['审查任务:'+value.status,'当前阶段:'+p.stage,'进度:'+(p.total===null?'正在准备,文件总量尚未确定':p.processed+'/'+p.total+' 个文件已处理('+p.percent+'%),其中主审完成 '+p.counts.completed+'、失败 '+p.counts.failed+'、阻断 '+p.counts.blocked+';处理比例不等于通过率'),value.nextAction??''].join(String.fromCharCode(10)):value.jobId?'后台任务已接受,尚未完成。继续调用 code_review_wait 等待终态;不要以任务启动作为最终审核结论。':'');return {json:JSON.stringify(value),markdown};};
|
|
10
|
+
ctx.tools.register({name:'code_review_start',description:'Start a durable background preview or confirmed review. Returns a job ID immediately, NOT task completion. Keep the user request active using code_review_wait until terminal status; never final-answer only that a task started. Preview phase never sends source. For execute supply the approved previewId and confirmed=true. Use code_review_status to collect progress/results; code_review_cancel to stop. Does not automatically resume after host restart or grant new source approval.',parameters:{type:'object',additionalProperties:false,properties:{phase:{type:'string',enum:['preview','execute']},previewArgs:{type:'object',description:'Same parameters as code_review_preview; do not include execute fields.'},previewId:{type:'string'},confirmed:{type:'boolean'}},required:['phase']},output,async execute(args,exec){if(!args||Object.keys(args).some(k=>!['phase','previewArgs','previewId','confirmed'].includes(k))||!['preview','execute'].includes(args.phase))throw new Error('Invalid background phase');if(args.phase==='execute'&&(args.confirmed!==true||typeof args.previewId!=='string'||args.previewArgs!==undefined))throw new Error('Confirmed preview required');if(args.phase==='preview'&&(args.previewId!==undefined||args.confirmed!==undefined))throw new Error('Preview cannot confirm execution');return respond(await jobs.start(exec,async({signal,checkpoint})=>{const original=exec.agent,sessionId=original.session.header.id??original.session.id,detached={...exec,agent:original,signal,reviewCheckpoint:async value=>{const active=exec.agent;if(active!==original||(active.session.header.id??active.session.id)!==sessionId){throw new Error('Background Agent/session changed');}await checkpoint(value);}};await checkpoint({stage:args.phase==='preview'?'capturing-preview':'revalidating-approved-source'});return args.phase==='preview'?service.preview(args.previewArgs??{},detached):service.execute({previewId:args.previewId,confirmed:true},detached);}));}});
|
|
11
11
|
ctx.tools.register({name:'code_review_status',description:'Read a background review task owned by this session, directory and model. Returns saved progress or final preview/report. completed task means the tool returned; inspect nested report coverage/status. Interrupted tasks retain checkpoints but require new preview and approval to resend.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'},offset:{type:'integer',minimum:0},checkpointIndex:{type:'integer',minimum:0,description:'Read one checkpoint nested files/findings page; offset/limit apply within it.'},limit:{type:'integer',minimum:1,maximum:100}},required:['jobId']},output,async execute(args,exec){return respond(await jobs.status(args.jobId,exec,{offset:args.offset??0,limit:args.limit??20,checkpointIndex:args.checkpointIndex}));}});
|
|
12
12
|
ctx.tools.register({name:'code_review_cancel',description:'Cancel a background review task owned by this session. Does not discard saved checkpoints. No source is sent by cancellation.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'}},required:['jobId']},output,async execute(args,exec){return respond(await jobs.cancel(args.jobId,exec));}});
|
|
13
|
+
ctx.tools.register({name:'code_review_wait',description:'Wait for review progress or terminal result without ending the user request. Call again while queued/running; show concise progress, not partial findings. A wait timeout does not cancel review or mean task completion. Preview completion requires confirmation; execute completion requires one final consolidated report.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'},after:{type:'integer',minimum:0}},required:['jobId']},output,async execute(args,exec){await jobs.wait(args.jobId,exec,{after:args.after,signal:exec.signal});const status=await jobs.status(args.jobId,exec,{offset:0,limit:20});if(!['queued','running'].includes(status.status)){const full=await jobs.status(args.jobId,exec);if(full.result?.markdown)status.finalMarkdown=full.result.markdown;}return respond(status);}});
|
|
13
14
|
ctx.on('dispose',()=>jobs.dispose());return jobs;
|
|
14
15
|
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import {recoveryCoverage} from './recovery-coverage.mjs';
|
|
2
|
+
export function jobProgress(record){const r=recoveryCoverage(record),counts={completed:0,excluded:0,blocked:0,failed:0,cancelled:0,pending:0};for(const f of r.files)counts[f.status]=(counts[f.status]??0)+1;const terminal=!['queued','running'].includes(record.status);return {stage:record.stage,terminal,elapsedMs:Date.now()-Date.parse(record.createdAt),total:r.manifestSaved?r.files.length:null,counts,processed:r.files.filter(f=>!['pending'].includes(f.status)).length,percent:r.manifestSaved&&r.files.length?Math.floor(100*r.files.filter(f=>!['pending'].includes(f.status)).length/r.files.length):null,notice:'Percent measures terminal primary-file processing, not successful review or synthesis completion. Unknown capture denominator is null, never invented.'};}
|
package/src/review-jobs.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {jobProgress} from './job-progress.mjs';
|
|
1
2
|
import {recoveryCoverage} from './recovery-coverage.mjs';
|
|
2
3
|
import {randomUUID} from 'node:crypto';
|
|
3
4
|
import {mkdir,writeFile,rename,readFile,lstat,realpath} from 'node:fs/promises';
|
|
@@ -6,20 +7,21 @@ import path from 'node:path';
|
|
|
6
7
|
// Report storage is host-owned, never inside a reviewed working copy.
|
|
7
8
|
export function createReviewJobs({directory}={}){
|
|
8
9
|
if(typeof directory!=='string'||!path.isAbsolute(directory))throw new Error('Absolute host report directory required');
|
|
9
|
-
const jobs=new Map();let disposed=false,canonicalDirectory;
|
|
10
|
+
const jobs=new Map(),listeners=new Map();const changed=id=>{for(const fn of listeners.get(id)??[])fn();listeners.delete(id);};let disposed=false,canonicalDirectory;
|
|
10
11
|
const identity=exec=>{const a=exec?.agent,s=a?.session;if(!s||!a.options?.provider||!a.options?.model||!s.header?.cwd)throw new Error('Review job requires session identity');const id=s.header.id??s.id;if(typeof id!=='string'||!id)throw new Error('Durable session id required');return {sessionId:id,cwd:s.header.cwd,provider:a.options.provider,model:a.options.model,reasoningEffort:a.options.reasoningEffort??null};};
|
|
11
12
|
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
12
13
|
const pageCheckpoint=(c,offset,limit)=>({...c,...(c.files?{files:c.files.slice(offset,offset+limit),totalFiles:c.files.length}:{}),...(c.findings?{findings:c.findings.slice(offset,offset+limit),totalFindings:c.findings.length}: {})});
|
|
13
14
|
const filename=id=>{if(typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))throw new Error('Invalid review job id');return path.join(directory,id+'.json');};
|
|
14
15
|
async function safeDirectory(){await mkdir(directory,{recursive:true,mode:0o700});const stat=await lstat(directory),resolved=await realpath(directory);if(stat.isSymbolicLink()||!stat.isDirectory()||(canonicalDirectory!==undefined&&resolved!==canonicalDirectory))throw new Error('Unsafe report directory');canonicalDirectory??=resolved;}
|
|
15
|
-
async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));}
|
|
16
|
+
async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));changed(job.id);}
|
|
16
17
|
async function owned(id,exec){const who=identity(exec),job=jobs.get(id);if(job){if(!same(who,job.record.owner))throw new Error('Foreign review job');return job.record;}await safeDirectory();const name=filename(id),stat=await lstat(name);if(stat.isSymbolicLink()||!stat.isFile())throw new Error('Unsafe report file');const r=JSON.parse(await readFile(name,'utf8'));if(!same(who,r.owner))throw new Error('Foreign review job');if(['queued','running'].includes(r.status)){r.status='interrupted';r.notice='Host restarted or plugin disposed. Saved checkpoints are available; resending requires a fresh preview and approval.';}return r;}
|
|
17
18
|
return {
|
|
18
19
|
async start(exec,work){if(disposed)throw new Error('Review job service disposed');exec.signal?.throwIfAborted();const who=identity(exec);const relative=path.relative(path.resolve(who.cwd),directory);if(relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)))throw new Error('Report store must be outside reviewed workspace');if([...jobs.values()].some(j=>j.record.owner.sessionId===who.sessionId&&['queued','running'].includes(j.record.status)))throw new Error('Session review job already active');const id=randomUUID(),controller=new AbortController(),job={id,controller,record:{jobId:id,owner:who,status:'queued',stage:'queued',checkpoints:[],createdAt:new Date().toISOString()},pending:Promise.resolve()};jobs.set(id,job);try{await save(job);}catch(error){jobs.delete(id);throw error;}const enqueue=fn=>{job.pending=job.pending.then(fn);return job.pending;};
|
|
19
20
|
// Own cancellation explicitly. The short start RPC ending is NOT task cancellation.
|
|
20
|
-
job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));}catch(error){job.record.status='failed';job.record.persistenceError=error.message;}}})();return {jobId:id,status:'queued',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
|
|
21
|
+
job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));}catch(error){job.record.status='failed';job.record.persistenceError=error.message;}}})();return {jobId:id,status:'queued',nextAction:'Call code_review_wait until terminal status; do not finish the user request at start.',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
|
|
21
22
|
},
|
|
22
|
-
async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
23
|
+
async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait; do not end the user review request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
24
|
+
async wait(id,exec,{after,timeoutMs=25000,signal}={}){const r=await owned(id,exec);if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');if(after!==undefined&&(!Number.isSafeInteger(after)||after<0))throw new Error('Invalid wait revision');const revision=r.checkpoints.length;if(!['queued','running'].includes(r.status)||(after!==undefined&&after!==revision))return {changed:true};signal?.throwIfAborted();await new Promise((resolve,reject)=>{let timer;const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);listeners.get(id)?.delete(done);};const done=()=>{cleanup();resolve();},abort=()=>{cleanup();reject(signal.reason);};if(!listeners.has(id))listeners.set(id,new Set());listeners.get(id).add(done);signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(done,timeoutMs);const latest=jobs.get(id)?.record;if(latest&&(latest.checkpoints.length!==revision||!['queued','running'].includes(latest.status)))done();if(signal?.aborted)abort();});return {changed:true};},
|
|
23
25
|
async cancel(id,exec){await owned(id,exec);const j=jobs.get(id);if(j)j.controller.abort(new Error('User cancelled review job'));return {jobId:id,cancelRequested:!!j};},
|
|
24
26
|
async dispose(){disposed=true;for(const j of jobs.values())j.controller.abort(new Error('Review plugin disposed'));await Promise.all([...jobs.values()].map(j=>j.done));}
|
|
25
27
|
};
|