Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/workbench-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,41 @@ Filtering uses the existing bounded metadata index and precedes twelve-row
pagination. Corrupt indexes remain counted separately as unavailable; they are
never treated as passed results. No report is opened, verification rerun, file
pruned or apply capability created by a search.


## Compare two saved verification records

History now has independent earlier/later record selectors. Pick two verification
records from their rows or enter their IDs, then compare. This reads their existing
metadata and digest-bound bodies; it never calls the verifier, changes the current
selection/baseline, consumes an approved plan or grants repair permission. Leaving
the history view, changing an ID or cancelling discards obsolete replies while
retaining the entered IDs and unrelated drafts. Comparisons use one bounded read
slot; a second concurrent comparison receives a recoverable busy response.

Both records must describe the same saved directory identity and verification
mode. Skill reports require matching declared targets and complete, unsaturated
comparison fields, consistent summaries and finding coverage, unique issue IDs and
matching recorded exit codes. The same owner computes fresh-baseline and historical
issue differences, including pairing changed evidence at the same code/location.
The result distinguishes new, continuing, no-longer-observed and changed evidence;
no-longer-observed findings are not certified fixes. Old reports do not record
all tool/policy conditions, so identical selection is not a controlled regression.

Artifact records retain the existing contract, declaration and receipt-bound
comparison rules. Their finding counts stay separate from per-check changes.
Different selections/contracts, incomplete or invalid evidence remain unavailable;
they do not produce invented zero counts. Corrupt or changed stored bodies fail
before comparison. Current source directories and contract files need not exist:
this is an offline comparison of saved evidence, not a fresh physical-file check.

POST `/api/history/compare` accepts `beforeId` and `afterId` under the existing
loopback origin and session guards. History still caps state at 120 files/64 MiB,
metadata at 16 KiB and each body at 8 MiB. Early row/field caps precede deep schema
parsing; saturated 256-row/unknown evidence is unavailable. At most 32 code/count
or reconciliation-index details reach the browser, with full accepted counts and
explicit truncation. Raw finding messages, source paths, cells, recipes and backup
paths are not returned. No history or receipt is rewritten.

The workflow borrows [Qodana's baseline distinctions](https://www.jetbrains.com/help/qodana/baseline.html)
while preserving this workbench's local, non-authenticated evidence boundary.
35 changes: 34 additions & 1 deletion src/workbench/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { isAbsolute, join } from "node:path";
import { z } from "zod";
import { parseStrictJson } from "../domain/strict-json.js";
import { readDirectoryEntries, readWorkspaceText } from "../sandbox/workspace-tree.js";
import {parseComparisonReport,compareVerificationReports} from "./verification-comparison.js";
import {compareArtifactReports,parseArtifactReport} from "../domain/artifact-report-comparison.js";

export const MAX_OUTPUT = 8 * 1024 * 1024;
const MAX_META = 16 * 1024;
Expand Down Expand Up @@ -94,9 +96,40 @@ export class WorkbenchHistory {
async read(id:unknown) {
await this.entries();
const record=await this.metadata(id);
return {record,output:await this.output(record)};
}
private async output(record:HistoryRecord) {
const output=record.dataFile?await readWorkspaceText(join(this.root,record.dataFile),MAX_OUTPUT):undefined;
if(output!==undefined && sha(output)!==record.dataSha256)throw new Error("历史材料摘要不匹配。");
return {record,output};
return output;
}
async compare(beforeId:unknown,afterId:unknown) {
const first=uuid.parse(beforeId),second=uuid.parse(afterId);
if(first===second)throw new WorkbenchStateError("请选择两份不同的历史记录。");
await this.entries();
const before=await this.metadata(first),after=await this.metadata(second);
const identity=(record:HistoryRecord)=>({id:record.id,createdAt:record.createdAt,reportSha256:record.dataSha256,exitCode:record.exitCode});
const base={schema:"skillsync.history-comparison/v1",before:identity(before),after:identity(after),kind:before.selection.kind,
readOnly:true,execution:"not-run",provenance:"not-authenticated"};
const unavailable=(reason:string)=>({...base,status:"unavailable" as const,reason});
if(before.kind!=="verify"||after.kind!=="verify")return unavailable("not-verification");
if(JSON.stringify(before.selection)!==JSON.stringify(after.selection))return unavailable("selection-changed");
// Scope predicates precede any body read. Do not reopen current source directories.
const left=await this.output(before),right=await this.output(after);
try {
if(before.selection.kind==="skill"){
const a=parseComparisonReport(parseStrictJson(left!)),b=parseComparisonReport(parseStrictJson(right!));
if(a.exitCode!==before.exitCode||b.exitCode!==after.exitCode)return unavailable("incomplete-evidence");
if(JSON.stringify(a.targets.map(row=>[row.name,row.scope,row.profileId]))!==JSON.stringify(b.targets.map(row=>[row.name,row.scope,row.profileId])))return unavailable("declared-targets-changed");
return {...base,...compareVerificationReports(a,b)};
}
const a=parseArtifactReport(parseStrictJson(left!)),b=parseArtifactReport(parseStrictJson(right!));
if(a.exitCode!==before.exitCode||b.exitCode!==after.exitCode)return unavailable("incomplete-evidence");
const compared=compareArtifactReports(a,b,before.dataSha256!);
if(compared.status!=="compared")return unavailable(compared.reason??"incomplete-evidence");
const details=(compared.checks??[]).filter(row=>row.change!=="unchanged").map(row=>({code:"reconciliation."+row.index,change:row.change,count:1}));
return {...base,status:"compared" as const,counts:compared.findings!,details:details.slice(0,32),detailsTotal:details.length,detailsTruncated:details.length>32};
}catch{return unavailable("invalid-evidence");}
}
}

Expand Down
53 changes: 46 additions & 7 deletions src/workbench/page.ts

Large diffs are not rendered by default.

25 changes: 12 additions & 13 deletions src/workbench/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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";
import { compareIssues } from "../domain/baseline.js";
import {compareVerificationReports} from "./verification-comparison.js";
import { compareArtifactReports, parseArtifactReport } from "../domain/artifact-report-comparison.js";
import {previewAcceptancePlan,inspectAcceptancePackage,batchSummary} from "../cli/commands/batch.js";
import {renderBatchHtml} from "../reporters/batch-html.js";
Expand All @@ -38,6 +38,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string;
const nonce = randomBytes(32).toString("hex");
const cliPath = options.cliPath ?? fileURLToPath(new URL("../cli/index.js", import.meta.url));
let active: ChildProcess | undefined, busy = false, applying = false, cancelled = false;
let comparingHistory=false;
let selection: Selection | undefined;
let actionable: string[] = [];
let plan: { id: string; path: string; value: ActionPlan; snapshot: string; historyId:string } | undefined;
Expand Down Expand Up @@ -105,6 +106,13 @@ export async function startWorkbench(options: { port?: number; stateDir: string;
if (req.url === "/api/history") return send(res,200,await history.list(input.offset===undefined?0:Number(input.offset),{
query:input.query??"",kind:input.kind,exitCode:input.exitCode,
}));
if(req.url==="/api/history/compare"){
if(comparingHistory)return send(res,409,{error:"已有历史对照读取中,请稍后重试。"});
comparingHistory=true;
try{return send(res,200,await history.compare(input.beforeId,input.afterId));}
catch(error){return send(res,400,{error:error instanceof WorkbenchStateError?error.message:"历史材料无法稳定读取,请检查两份原记录;没有重新执行核验。"});}
finally{comparingHistory=false;}
}
if (busy) return send(res, 409, {error: "已有操作进行中。"});
busy = true; cancelled = false;
try {
Expand Down Expand Up @@ -202,18 +210,9 @@ export async function startWorkbench(options: { port?: number; stateDir: string;
if(selected.kind==="artifacts") {
const compared=compareArtifactReports(baseline.report as ArtifactReport,parseArtifactReport(report),baseline.digest);
comparison=compared.status==="compared"?{status:"compared",added:compared.findings!.added,ongoing:compared.findings!.ongoing,resolved:compared.findings!.resolved}:{status:"unavailable",reason:compared.reason};
} else if([baseline.report.exitCode,report.exitCode].some(c=>c===2||c===4)||[(baseline.report as VerificationReport).issues,report.issues].some(v=>v.length>=256))comparison={status:"unavailable",reason:"incomplete-evidence"};
else {
const pending=(value:VerificationReport)=>value.issues.filter(issue=>issue.finding.status==="fail"||issue.finding.status==="warn");
const before=pending(baseline.report as VerificationReport),after=pending(report),c=compareIssues(before,after);
// A warning may bind the entire Skill digest. Pair changed evidence
// within the same code/location, rather than claiming it was fixed.
const group=(issue:VerificationReport["issues"][number])=>JSON.stringify([issue.finding.code,issue.finding.skill,issue.finding.target,issue.location]);
const removed=new Map<string,number>();
for(const issue of before.filter(i=>c.resolvedIds.includes(i.id)))removed.set(group(issue),(removed.get(group(issue))??0)+1);
let changed=0;
for(const issue of after.filter(i=>c.newIds.includes(i.id))) {const key=group(issue),count=removed.get(key)??0;if(count){changed++;removed.set(key,count-1);}}
comparison={status:"compared",added:c.newIds.length-changed,ongoing:c.ongoingIds.length,resolved:c.resolvedIds.length-changed,changed};
} else {
const compared=compareVerificationReports(baseline.report as VerificationReport,report);
comparison=compared.status==='compared'?{status:'compared',...compared.counts}:{status:'unavailable',reason:compared.reason};
}
}
const saved=await history.append({kind:"verify",selection:selected,snapshot,baselineId:baseline?.id,parentId:latestId,exitCode:report.exitCode,comparison},output);
Expand Down
67 changes: 67 additions & 0 deletions src/workbench/verification-comparison.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {z} from "zod";
import type {VerificationReport} from "../cli/commands/verify.js";
import {compareIssues} from "../domain/baseline.js";
import {issueIdForFinding} from "../domain/issue.js";
import {FindingSchema} from "../domain/result.js";

export type IssueCounts={added:number;ongoing:number;resolved:number;changed:number};
export type ComparisonDetail={code:string;change:string;count:number};
type Report=Pick<VerificationReport,"issues"|"findings"|"summary"|"exitCode">;
const text=z.string().min(1).max(4096);
const finding=FindingSchema.refine(row=>row.code.length<=128&&/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(row.code)
&&row.skill.length<=4096&&(row.target?.length??0)<=4096&&row.message.length<=4096&&(row.remediation?.length??0)<=4096
&&row.evidence.length<=256&&row.evidence.every(item=>Object.keys(item).length<=32&&Object.values(item).every(value=>value.length<=4096)));
const count=z.number().int().min(0).max(256);
const schema=z.object({schema_version:z.literal(1),generated_at:z.string().datetime(),
targets:z.array(z.object({name:text,path:text,scope:z.enum(["explicit","project","user"]),profileId:text.optional()})).min(1).max(64),
findings:z.array(finding).max(256),issues:z.array(z.object({id:z.string().regex(/^iss_[a-f0-9]{64}$/),
state:z.enum(["open","acknowledged","resolved","ignored","regressed"]),finding,
location:z.object({path:text,line:z.number().int().nonnegative().safe().optional()}).passthrough().optional(),
})).max(256),summary:z.object({total:count,pass:count,warn:count,fail:count,unknown:count}).strict(),
exitCode:z.union([z.literal(0),z.literal(1),z.literal(2),z.literal(3),z.literal(4)]),
});

/** Validate comparison fields; unused explanations/repair recipes are never consumed. */
export function parseComparisonReport(value:unknown) {
// Zod arrays collect child errors even after max() fails. Reject hostile widths
// before deep parsing, while the 8 MiB history reader remains the byte bound.
const object=(v:unknown):v is Record<string,unknown>=>Boolean(v&&typeof v==="object"&&!Array.isArray(v));
if(!object(value)||Object.keys(value).length>16||!Array.isArray(value.targets)||value.targets.length>64
||value.targets.some(row=>!object(row)||Object.keys(row).length>8)
||!Array.isArray(value.findings)||value.findings.length>256||!Array.isArray(value.issues)||value.issues.length>256)throw new Error("History capacity");
const candidates=[...value.findings,...value.issues.map(row=>object(row)?row.finding:undefined)];
if(candidates.some(row=>!object(row)||Object.keys(row).length>16||!Array.isArray(row.evidence)||row.evidence.length>256
||row.evidence.some(item=>!object(item)||Object.keys(item).length>32||Object.values(item).some(v=>typeof v!=="string"||v.length>4096))))throw new Error("History evidence capacity");
const parsed=schema.parse(value);
const actual={total:parsed.findings.length,pass:0,warn:0,fail:0,unknown:0};
for(const item of parsed.findings)actual[item.status]++;
const expected=new Set(parsed.findings.map(item=>issueIdForFinding(item))),observed=new Set(parsed.issues.map(item=>issueIdForFinding(item.finding)));
if(Object.keys(actual).some(key=>actual[key as keyof typeof actual]!==parsed.summary[key as keyof typeof actual])
||new Set(parsed.issues.map(item=>item.id)).size!==parsed.issues.length||expected.size!==observed.size||[...expected].some(key=>!observed.has(key)))throw new Error("Incomplete history findings");
return parsed;
}

type Compared={status:"compared";counts:IssueCounts;details:ComparisonDetail[];detailsTotal:number;detailsTruncated:boolean};
type Unavailable={status:"unavailable";reason:"incomplete-evidence"};
type NeededReport=Pick<Report,"findings"|"summary"|"exitCode">&{issues:Array<Pick<Report["issues"][number],"id"|"finding"|"location">>};

/** Same owner for fresh/baseline and saved/saved comparisons. No raw messages or paths leave it. */
export function compareVerificationReports(before:NeededReport,after:NeededReport):Compared|Unavailable {
if([before,after].some(report=>report.exitCode===2||report.exitCode===4||report.issues.length>=256||report.findings.length>=256||report.summary.unknown>0))return {status:"unavailable",reason:"incomplete-evidence"};
const pending=(report:NeededReport)=>report.issues.filter(issue=>issue.finding.status==="fail"||issue.finding.status==="warn");
const left=pending(before),right=pending(after),comparison=compareIssues(left,right);
const added=new Set(comparison.newIds),absent=new Set(comparison.resolvedIds),ongoing=new Set(comparison.ongoingIds);
const group=(issue:NeededReport["issues"][number])=>JSON.stringify([issue.finding.code,issue.finding.skill,issue.finding.target,issue.location]);
const removed=new Map<string,Array<NeededReport["issues"][number]>>();
for(const issue of left)if(absent.has(issue.id)){const key=group(issue),items=removed.get(key)??[];items.push(issue);removed.set(key,items);}
const details=new Map<string,ComparisonDetail>();
const add=(code:string,change:string)=>{const key=JSON.stringify([code,change]);const row=details.get(key)??{code,change,count:0};row.count++;details.set(key,row);};
let changed=0;
for(const issue of right){
if(ongoing.has(issue.id))add(issue.finding.code,"ongoing");
else if(added.has(issue.id)){const paired=removed.get(group(issue))?.pop();if(paired){changed++;add(issue.finding.code,"changed");}else add(issue.finding.code,"added");}
}
for(const items of removed.values())for(const issue of items)add(issue.finding.code,"resolved");
const rows=[...details.values()].sort((a,b)=>a.code.localeCompare(b.code)||a.change.localeCompare(b.change));
return {status:"compared",counts:{added:comparison.newIds.length-changed,ongoing:comparison.ongoingIds.length,resolved:comparison.resolvedIds.length-changed,changed},details:rows.slice(0,32),detailsTotal:rows.length,detailsTruncated:rows.length>32};
}
Loading
Loading