diff --git a/README.md b/README.md index 8e52695..6541d71 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ as the baseline and explicitly verify the current directory. Historical plans are read-only. New, ongoing and no-longer-observed issues appear after recheck; the downloadable summary omits private paths. See [history and capacity limits](docs/workbench-history.md). +Current source also supports [multi-delivery acceptance plans and portable review packages](docs/batch-acceptance.md): preflight several deliveries, retain passed evidence while rechecking failures, and reopen a digest-bound package offline from the CLI or workbench. Build from source for these additions; existing release downloads remain versioned independently. + ## Install **From GitHub releases (no npm account required):** diff --git a/docs/batch-acceptance.md b/docs/batch-acceptance.md new file mode 100644 index 0000000..58a2d3e --- /dev/null +++ b/docs/batch-acceptance.md @@ -0,0 +1,36 @@ +# Multi-delivery acceptance and portable review + +Current source adds an explicit acceptance plan over one artifact contract and up to 16 named artifact or SkillTape delivery directories. It runs the existing physical-file engine sequentially, never Skill code. A result passes only when every current batch has complete passing evidence. Incomplete input, saturated findings or unverifiable delivery binding remain incomplete. + +```json +{ + "schema": "skillsync.acceptance-plan/v1", + "contract": "contract.json", + "batches": [ + {"id": "north", "path": "deliveries/north", "kind": "artifacts"}, + {"id": "south", "path": "deliveries/south", "kind": "artifacts"} + ] +} +``` + +Paths are relative to the plan directory. Choose real directories without symbolic parents and declare only contract files. Undeclared files/directories are rejected before body inspection. Delivery mode also expects the existing receipt and delivery manifest. + +```bash +skillsync batch preview --plan /project/acceptance-plan.json +skillsync batch run --plan /project/acceptance-plan.json --output /project/first-review.json +# Correct failed sources, then produce a separate immutable package. +skillsync batch run --plan /project/acceptance-plan.json --previous /project/first-review.json --retry-failed --output /project/corrected-review.json +skillsync batch inspect --package /project/corrected-review.json --contract /project/contract.json --plan /project/acceptance-plan.json --format html > review.html +``` + +The run exits 0 for all passed, 1 for failed, or 2 for incomplete/invalid evidence. Inspection exits 0 when the package is internally valid even if its acceptance result failed; use the embedded status/exitCode when making an acceptance decision. Output files must be new; existing packages and source files are never overwritten. A retry verifies passed input hashes before reusing evidence. Changed contract bytes, case identities or passed input content require a fresh run, without reusing the previous package. Current and prior reports remain available for comparison; unknown or truncated evidence cannot claim resolved findings. + +In **gui**, expand the multi-batch acceptance and offline review section, choose a plan and preflight it, then explicitly run into a new file. After a correction, select the prior package and the failed-only option, preflight again and run into another new file. Open an existing package read-only with an optional expected SHA-256 obtained independently. Opening evidence clears any single-Skill repair capability. Cancellation may retain an already published package: inspect the chosen output before retrying. + +The single JSON package includes the exact original plan SHA-256, the public declaration, per-batch file inventory, file/report hashes, complete bounded reports and comparisons. It omits source bodies, raw record values, contract expected scalar values and absolute source paths. Relative file names, case names and column metadata may still be sensitive. The scriptless HTML view can be reconstructed offline; oversized detail views are omitted with a visible notice, while the bounded JSON evidence remains. + +Shared admission limits: 16 batches, 10,000 files, 20,000 input entries, 16 MiB/file and 64 MiB total input. Each report is at most 1 MiB, all current/prior reports together at most 8 MiB, and the final package at most 16 MiB. Plan metadata is limited to 256 KiB and contract metadata to 64 KiB. Processing is serial; metadata is checked before file bodies and stable source bindings are rechecked. + +Package verification checks internal structure, counts, file/report digest bindings and comparison consistency. An expected package SHA-256 binds exact bytes; supplying the original contract also verifies its exact bytes and declaration scope. Optional `--plan` checks exact plan bytes without reading source data; a source-path mapping change also invalidates prior-plan reuse. These checks do not authenticate authorship, fetch cited sources, rerun original business data, or establish production acceptance. A self-consistent replacement package can be written by anyone; keep the digest through an independent trusted channel. + +The design independently applies declared validation batches and retained validation results described by [Great Expectations Checkpoints](https://docs.greatexpectations.io/docs/reference/api/checkpoint_class/) and [Frictionless validation](https://framework.frictionlessdata.io/docs/guides/validating-data). No upstream code or runtime dependency is copied. This source change does not republish existing v0.3.0 downloads or npm releases. diff --git a/src/cli/commands/batch.ts b/src/cli/commands/batch.ts new file mode 100644 index 0000000..54f3f5b --- /dev/null +++ b/src/cli/commands/batch.ts @@ -0,0 +1,175 @@ +import {link,lstat,mkdtemp,open,realpath,rm,writeFile} from "node:fs/promises"; +import {dirname,isAbsolute,join,relative,resolve} from "node:path"; +import {randomUUID} from "node:crypto"; +import {parse as parseYaml} from "yaml"; +import {parseStrictJson} from "../../domain/strict-json.js"; +import {parseArtifactContract} from "../../domain/artifact-contract.js"; +import {artifactSetHash,parseArtifactReport,type ArtifactReport} from "../../domain/artifact-report.js"; +import {compareArtifactReports} from "../../domain/artifact-report-comparison.js"; +import {acceptancePlanSchema,BATCH_LIMITS,BatchAcceptanceError,canonicalJson,contentHash,sha256,parseReviewPackage,validateBatchReport,batchStatus,summarizeBatch, + type AcceptancePlan,type AcceptanceDeclaration,type AcceptanceBatch,type BatchReport,type ReviewPackage} from "../../domain/batch-report.js"; +import {readDirectoryEntries,readWorkspaceFile,scanStagedWorkspace} from "../../sandbox/workspace-tree.js"; +import {redactLocalPaths} from "../../reporters/local-paths.js"; +import {runArtifacts} from "./artifacts.js"; +import {VERSION} from "../../version.js"; + +export type BatchRunOptions={plan:string;output:string;previous?:string;retryFailed?:boolean;expectPlanSha256?:string}; +type Source={path:string;allowed:Set;files:number;bytes:number;stamp:string;available:boolean}; +const error=(code:string,message:string)=>new BatchAcceptanceError(code,message); +const inside=(root:string,path:string)=>{const value=relative(root,path);return !value||!value.startsWith('..')&&!isAbsolute(value);}; + +function alias(path:string):string { + if(process.platform!=='darwin')return path; + for(const name of ['tmp','var','etc'])if(path===`/${name}`||path.startsWith(`/${name}/`))return '/private'+path; + return path; +} +async function directory(path:string):Promise { + const lexical=alias(resolve(path)),canonical=await realpath(lexical),info=await lstat(lexical); + if(!info.isDirectory()||info.isSymbolicLink()||canonical!==lexical)throw error('batch.unsafe-source','Choose a real source directory without links.'); + return canonical; +} +async function contract(path:string){ + const bytes=await readWorkspaceFile(path,64*1024); + const value=parseArtifactContract(parseYaml(new TextDecoder('utf-8',{fatal:true}).decode(bytes),{maxAliasCount:100,prettyErrors:false,logLevel:'silent'})); + if(!value.files.length)throw error('batch.empty-contract','A batch acceptance contract must declare at least one file.'); + return {bytes,value,semantic:contentHash(value),file:sha256(bytes),engine:sha256(JSON.stringify(value))}; +} + +async function metadata(path:string,allowed:Set,shared?:{entries:number;files:number;bytes:number}):Promise> { + const items:Array<[string,number,number,number,number,number,number]>=[];let files=0,bytes=0,entries=0; + const parents=new Set();for(const name of allowed){const parts=name.split('/');for(let index=1;index{ + if(depth>64||++entries>20000)throw error('batch.capacity','Selected input metadata exceeds the shared capacity.'); + if(shared&&++shared.entries>BATCH_LIMITS.max_input_entries)throw error('batch.capacity','The combined inputs exceed 20000 directory entries.'); + const info=await lstat(current); + if(info.isSymbolicLink()||!info.isFile()&&!info.isDirectory())throw error('batch.unsafe-source','Selected input contains a link or special file.'); + items.push([key,info.dev,info.ino,info.mode,info.size,info.mtimeMs,info.ctimeMs]); + if(info.isDirectory()){ + if(key&&!parents.has(key))throw error('batch.input-layout','Selected input has undeclared directories.'); + for(const entry of await readDirectoryEntries(current,20000))await walk(join(current,entry.name),key?key+'/'+entry.name:entry.name,depth+1); + }else{ + if(!allowed.has(key))throw error('batch.input-layout','Selected input has undeclared files.'); + files++;bytes+=info.size; + if(shared){shared.files++;shared.bytes+=info.size;if(shared.files>BATCH_LIMITS.max_input_files||shared.bytes>BATCH_LIMITS.max_input_bytes)throw error('batch.capacity','The combined inputs exceed 64 MiB or 10000 files.');} + if(!Number.isSafeInteger(info.size)||info.size>16*1024*1024||files>BATCH_LIMITS.max_input_files||bytes>BATCH_LIMITS.max_input_bytes)throw error('batch.capacity','Selected input exceeds the shared file or byte capacity.'); + } + } + await walk(path,'',0);items.sort((left,right)=>left[0].localeCompare(right[0]));return {files,bytes,stamp:contentHash(items)}; +} +async function planContext(path:string){ + const selected=alias(resolve(path)),base=await directory(dirname(selected)); + const raw=await readWorkspaceFile(selected,256*1024),plan:AcceptancePlan=acceptancePlanSchema.parse(parseStrictJson(new TextDecoder('utf-8',{fatal:true}).decode(raw))); + const contractPath=join(base,plan.contract);if(await realpath(contractPath)!==contractPath)throw error('batch.unsafe-contract','Select a contract without symbolic parents.'); + const checked=await contract(contractPath); + // Use the same normalized digest as the existing artifact engine. + const declaration:AcceptanceDeclaration={schema:'skillsync.acceptance-declaration/v1',plan_sha256:sha256(raw),contract_sha256:checked.engine,contract_file_sha256:checked.file, + file_paths:checked.value.files.map(file=>file.path).sort(),batches:plan.batches.map(({id,kind})=>({id,kind}))}; + const sources:Source[]=[];const shared={entries:0,files:0,bytes:0}; + for(const item of plan.batches){ + const target=join(base,item.path),allowed=new Set(item.kind==='delivery'?['receipt.json','delivery.json',...declaration.file_paths.map(file=>'artifacts/'+file)]:declaration.file_paths); + try{ + const root=await directory(target);if(!inside(base,root))throw error('batch.unsafe-source','A batch input escaped the plan directory.');const stats=await metadata(root,allowed,shared); + sources.push({path:root,allowed,...stats,available:true}); + }catch(problem){ + if(problem instanceof BatchAcceptanceError&&problem.code==='batch.capacity')throw problem; + sources.push({path:target,allowed,files:0,bytes:0,stamp:'',available:false}); + } + } + return {path:selected,base,raw_sha256:sha256(raw),plan,contractPath,checked,declaration,sources}; +} + +export async function previewAcceptancePlan(path:string){ + try{const context=await planContext(path);return {schema:'skillsync.batch-preview/v1',plan_sha256:context.raw_sha256, + batches:context.plan.batches.map((item,index)=>({id:item.id,kind:item.kind,files:context.sources[index].files,bytes:context.sources[index].bytes,available:context.sources[index].available})), + suggestedOutput:join(context.base,'skillsync-acceptance-'+randomUUID()+'.json'),limits:BATCH_LIMITS}; + }catch(problem){if(problem instanceof BatchAcceptanceError)throw problem;throw error('batch.plan-invalid','The acceptance plan or contract is invalid or unavailable.');} +} + +export async function inspectAcceptancePackage(path:string,options:{expectSha256?:string;contract?:string;plan?:string}={}){ + try{ + const selected=alias(resolve(path));await directory(dirname(selected));const raw=await readWorkspaceFile(selected,BATCH_LIMITS.max_package_bytes),digest=sha256(raw); + if(options.expectSha256&&options.expectSha256!==digest)throw error('batch.package-digest','The review package does not match the expected digest.'); + const packet=parseReviewPackage(parseStrictJson(new TextDecoder('utf-8',{fatal:true}).decode(raw))); + if(options.plan){ + const planRaw=await readWorkspaceFile(alias(resolve(options.plan)),256*1024); + const plan=acceptancePlanSchema.parse(parseStrictJson(new TextDecoder('utf-8',{fatal:true}).decode(planRaw))); + if(sha256(planRaw)!==packet.payload.declaration.plan_sha256||canonicalJson(plan.batches.map(({id,kind})=>({id,kind})))!==canonicalJson(packet.payload.declaration.batches))throw error('batch.plan-changed','The supplied plan does not bind this package.'); + } + if(options.contract){ + const checked=await contract(alias(resolve(options.contract))),declared={limits:checked.value.limits,checks:checked.value.checks.map((check,index)=>({ordinal:index+1,check}))}; + if(checked.file!==packet.payload.declaration.contract_file_sha256||checked.engine!==packet.payload.declaration.contract_sha256||canonicalJson(checked.value.files.map(file=>file.path).sort())!==canonicalJson(packet.payload.declaration.file_paths)||packet.payload.batches.some(item=>item.report.declarations&&canonicalJson(item.report.declarations)!==canonicalJson(declared)))throw error('batch.contract-changed','The supplied contract does not bind this package.'); + } + return {packet,package_sha256:digest}; + }catch(problem){if(problem instanceof BatchAcceptanceError)throw problem;throw error('batch.package-invalid','The review package could not be verified.');} +} + +function unavailable(contractSha:string,code:string):ArtifactReport { + return {schema:'skillsync.artifacts-report/v1',status:'failed',exitCode:2,evidence:'physical-files',execution:'not-run',provenance:'not-authenticated',receipt_binding:'not-provided',contract_sha256:contractSha,files:[],findings:[{code}]}; +} +async function snapshot(source:Source){ + if(!source.available)throw error('batch.source-unavailable','A batch input is unavailable.'); + if((await metadata(source.path,source.allowed)).stamp!==source.stamp)throw error('batch.source-changed','A batch input changed after preflight.'); + return scanStagedWorkspace(source.path,{allowedFiles:source.allowed}); +} +async function publish(path:string,content:string){ + const selected=alias(resolve(path));if(/[\p{Cc}]/u.test(path)||path.split(/[\\/]/).includes('..'))throw error('batch.output-invalid','Select a new output file without traversal.'); + const parent=await directory(dirname(selected)),identity=await lstat(parent); + try{await lstat(selected);throw error('batch.output-exists','The output already exists; preserve it and choose a new file.');}catch(problem){if(!(problem instanceof Error&&'code'in problem&&problem.code==='ENOENT'))throw problem;} + const stage=await mkdtemp(join(parent,'.skillsync-package-')); + try{ + const file=join(stage,'package.json');await writeFile(file,content,{flag:'wx',mode:0o600});const handle=await open(file,'r');try{await handle.sync();}finally{await handle.close();} + const current=await lstat(parent);if(current.dev!==identity.dev||current.ino!==identity.ino||await realpath(parent)!==parent)throw error('batch.output-changed','The output directory changed during publication.'); + await link(file,selected); // Atomic no-replace publication; no rename over a user's empty directory. + if(process.platform!=='win32'){const directoryHandle=await open(parent,'r');try{await directoryHandle.sync();}finally{await directoryHandle.close();}} + }catch(problem){if(problem instanceof BatchAcceptanceError)throw problem;throw error('batch.output-unavailable','Package publication failed. Inspect any retained output before retrying.');} + finally{await rm(stage,{recursive:true,force:true});} +} + +export async function runAcceptanceBatch(options:BatchRunOptions){ + try{ + const context=await planContext(options.plan); + if(options.expectPlanSha256&&context.raw_sha256!==options.expectPlanSha256)throw error('batch.plan-changed','The plan changed after preview. Review it again.'); + if(options.retryFailed&&!options.previous)throw error('batch.previous-required','Retrying failed batches requires an explicit previous package.'); + const selected=alias(resolve(options.output)); + if(selected===context.path||selected===context.contractPath||context.sources.some(source=>inside(source.path,selected)))throw error('batch.output-overlap','Keep review packages outside source data and contract files.'); + try{await lstat(selected);throw error('batch.output-exists','The output already exists; choose a new file.');}catch(problem){if(!(problem instanceof Error&&'code'in problem&&problem.code==='ENOENT'))throw problem;} + const previous=options.previous?await inspectAcceptancePackage(options.previous):undefined; + const declarationSha=contentHash(context.declaration); + if(previous&&previous.packet.payload.declaration_sha256!==declarationSha)throw error('batch.previous-plan-changed','The prior package belongs to a different plan or contract.'); + const rows:AcceptanceBatch[]=[];let evidenceBytes=0; + for(const[index,item]of context.plan.batches.entries()){ + const source=context.sources[index],before=previous?.packet.payload.batches.find(row=>row.id===item.id); + let tree:Awaited>|undefined,report:ArtifactReport,mode:AcceptanceBatch['mode']='inspected'; + try{tree=await snapshot(source);}catch{tree=undefined;} + const inventory=tree?.files.map(file=>({path:file.path.slice('workspace/'.length),bytes:file.bytes,sha256:file.digest.slice('sha256:'.length)})); + const sourceSha=inventory?artifactSetHash(inventory):undefined; + if(options.retryFailed&&before?.status==='passed'){ + if(!sourceSha||sourceSha!==before.source_sha256)throw error('batch.previous-input-changed','A previously passed input changed; run a new full acceptance plan.'); + report=before.report;mode='reused'; + }else if(tree){ + report=await runArtifacts({contract:context.contractPath,...(item.kind==='delivery'?{delivery:source.path}:{path:source.path}),includeRowEvidence:true}); + report=parseArtifactReport(redactLocalPaths(report)); + try{if((await snapshot(source)).digest!==tree.digest)report=unavailable(context.checked.engine,'artifact.batch-input-changed');}catch{report=unavailable(context.checked.engine,'artifact.batch-input-changed');} + }else report=unavailable(context.checked.engine,'artifact.batch-input-unavailable'); + if(Buffer.byteLength(canonicalJson(report))>BATCH_LIMITS.max_report_bytes)report=unavailable(context.checked.engine,'artifact.batch-report-capacity'); + const row:AcceptanceBatch={id:item.id,kind:item.kind,status:batchStatus(report,sourceSha,context.declaration,item.kind),mode, + ...(sourceSha?{source_sha256:sourceSha,source_inventory:inventory}:{}),source_files:tree?.files.length??0,source_bytes:tree?.files.reduce((sum,file)=>sum+file.bytes,0)??0,report_sha256:contentHash(report),report}; + if(before&&mode==='inspected'){ + row.previous={report_sha256:before.report_sha256,report:before.report};row.comparison=compareArtifactReports(before.report,report,before.report_sha256); + } + evidenceBytes+=Buffer.byteLength(canonicalJson(report))+Buffer.byteLength(canonicalJson(row.previous?.report??null)); + if(evidenceBytes>BATCH_LIMITS.max_reports_bytes)throw error('batch.evidence-capacity','The combined report evidence exceeds 8 MiB; use a smaller plan.'); + rows.push(row); + } + if(sha256(await readWorkspaceFile(context.contractPath,64*1024))!==context.checked.file||sha256(await readWorkspaceFile(context.path,256*1024))!==context.raw_sha256)throw error('batch.plan-changed','The plan or contract changed during acceptance.'); + const payload:BatchReport={schema:'skillsync.batch-report/v1',createdAt:new Date().toISOString(),tool_version:VERSION,declaration:context.declaration,declaration_sha256:declarationSha, + ...(previous?{previous_package_sha256:previous.package_sha256}:{}),...summarizeBatch(rows),limits:BATCH_LIMITS,evidence:'physical-files',execution:'not-run',provenance:'not-authenticated',batches:rows}; + validateBatchReport(payload);const packet:ReviewPackage={schema:'skillsync.review-package/v1',payload_sha256:contentHash(payload),payload}; + const content=JSON.stringify(packet);if(Buffer.byteLength(content)>BATCH_LIMITS.max_package_bytes)throw error('batch.package-capacity','The review package exceeds 16 MiB.'); + parseReviewPackage(parseStrictJson(content));await publish(options.output,content+'\n'); + return {packet,package_sha256:sha256(content+'\n')}; + }catch(problem){if(problem instanceof BatchAcceptanceError)throw problem;throw error('batch.acceptance-failed','Batch acceptance could not complete; preserve existing inputs and packages.');} +} + +export function batchSummary(report:BatchReport){return {schema:report.schema,status:report.status,exitCode:report.exitCode,counts:report.counts,declaration_sha256:report.declaration_sha256, + batches:report.batches.map(({id,kind,status,mode,report_sha256,comparison})=>({id,kind,status,mode,report_sha256,...(comparison?{comparison}:{})})),execution:report.execution,provenance:report.provenance};} diff --git a/src/cli/index.ts b/src/cli/index.ts index f9cb9c6..b7a34bb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -39,6 +39,8 @@ import { } from "./commands/fix.js"; import { ActionPlanApplyError } from "../domain/patch-application.js"; import { runArtifacts, renderArtifacts, type ArtifactsOptions } from "./commands/artifacts.js"; +import {runAcceptanceBatch,inspectAcceptancePackage,previewAcceptancePlan,batchSummary,type BatchRunOptions} from "./commands/batch.js"; +import {renderBatchHtml} from "../reporters/batch-html.js"; import { listBehaviorFixtures, renderBehaviorTest, @@ -584,6 +586,27 @@ export function createCli(io: CliIO = defaultCliIO): Command { } }); + const batch=program.command('batch').description('Run multi-delivery acceptance and verify portable offline evidence.'); + batch.command('preview').requiredOption('--plan ','Explicit acceptance plan').action(async(options:{plan:string})=>{ + io.writeOut(JSON.stringify(await previewAcceptancePlan(options.plan),null,2)+'\n'); + }); + batch.command('run').requiredOption('--plan ','Explicit acceptance plan').requiredOption('--output ','New private review-package JSON file') + .option('--previous ','Prior immutable review package').option('--retry-failed','Reuse only unchanged passed batches; inspect failed/incomplete batches again') + .option('--expect-plan-sha256 ','Bind an explicitly previewed plan file').option('--format ','Summary format: text or json','text') + .action(async(options:BatchRunOptions&{format?:string})=>{ + if(!['text','json'].includes(options.format??'text'))throw new Error('Batch summary format must be text or json.'); + const result=await runAcceptanceBatch(options),summary={...batchSummary(result.packet.payload),package_sha256:result.package_sha256}; + io.writeOut(options.format==='json'?JSON.stringify(summary,null,2)+'\n':`Batch acceptance: ${summary.status}; passed=${summary.counts.passed}, failed=${summary.counts.failed}, incomplete=${summary.counts.incomplete}\nReview package SHA-256: ${summary.package_sha256}\n`); + if(summary.exitCode)io.setExitCode?.(summary.exitCode); + }); + batch.command('inspect').requiredOption('--package ','Review-package JSON file').option('--expect-sha256 ','Expected package digest from a separately trusted source') + .option('--contract ','Optionally verify the original contract binding').option('--plan ','Optionally verify the exact original plan bytes without reading its sources').option('--format ','Output format: text, json or offline html','text') + .action(async(options:{package:string;expectSha256?:string;contract?:string;plan?:string;format?:string})=>{ + if(!['text','json','html'].includes(options.format??'text'))throw new Error('Batch inspection format must be text, json or html.'); + const result=await inspectAcceptancePackage(options.package,options); + io.writeOut(options.format==='html'?renderBatchHtml(result.packet.payload):options.format==='json'?JSON.stringify({integrity:'verified',original_data:'not-rechecked',package_sha256:result.package_sha256,...batchSummary(result.packet.payload)},null,2)+'\n':`Review-package integrity: verified\nRecorded batch outcome: ${result.packet.payload.status}\nOriginal data not rechecked; authors not authenticated.\n`); + }); + program.command("artifacts") .description("Inspect actual local delivery artifacts against a bounded contract.") .requiredOption("--contract ", "JSON or YAML artifact contract") diff --git a/src/domain/batch-report.ts b/src/domain/batch-report.ts new file mode 100644 index 0000000..7af7536 --- /dev/null +++ b/src/domain/batch-report.ts @@ -0,0 +1,110 @@ +import {createHash} from "node:crypto"; +import {z} from "zod"; +import {parseArtifactReport, artifactSetHash, type ArtifactReport, artifactComparisonSchema} from "./artifact-report.js"; +import {compareArtifactReports} from "./artifact-report-comparison.js"; + +export const BATCH_LIMITS={max_batches:16,max_input_files:10000,max_input_entries:20000,max_input_bytes:67108864,max_report_bytes:1048576,max_reports_bytes:8388608,max_package_bytes:16777216} as const; +const sha=z.string().regex(/^[a-f0-9]{64}$/); +const id=z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,47}$/); +const path=z.string().min(1).max(1024).refine(value=>!value.includes('\\')&&!value.includes(':')&&!/\p{Cc}/u.test(value)&&value.split('/').every(part=>part&&part!=='.'&&part!=='..')); +const kind=z.enum(['artifacts','delivery']); +export const acceptancePlanSchema=z.object({schema:z.literal('skillsync.acceptance-plan/v1'),contract:path,batches:z.array(z.object({id,path,kind:kind.default('artifacts')}).strict()).min(1).max(16)}).strict().superRefine((plan,context)=>{ + if(new Set(plan.batches.map(item=>item.id.toLowerCase())).size!==plan.batches.length)context.addIssue({code:z.ZodIssueCode.custom,message:'duplicate batch identity'}); +}); +export type AcceptancePlan=z.infer; +const declarationSchema=z.object({schema:z.literal('skillsync.acceptance-declaration/v1'),plan_sha256:sha,contract_sha256:sha,contract_file_sha256:sha,file_paths:z.array(path).min(1).max(10000),batches:z.array(z.object({id,kind}).strict()).min(1).max(16)}).strict(); +export type AcceptanceDeclaration=z.infer; +const report=z.unknown().transform((value,context):ArtifactReport=>{ + try{return parseArtifactReport(value);}catch{context.addIssue({code:z.ZodIssueCode.custom,message:'invalid artifact report'});return z.NEVER;} +}); +const previousSchema=z.object({report_sha256:sha,report}).strict(); +const sourceFile=z.object({path,bytes:z.number().int().min(0).max(16777216),sha256:sha}).strict(); +const caseSchema=z.object({id,kind,status:z.enum(['passed','failed','incomplete']),mode:z.enum(['inspected','reused']),source_inventory:z.array(sourceFile).max(10000).optional(), + source_sha256:sha.optional(),source_files:z.number().int().min(0).max(10000),source_bytes:z.number().int().min(0).max(BATCH_LIMITS.max_input_bytes), + report_sha256:sha,report,previous:previousSchema.optional(),comparison:artifactComparisonSchema.optional()}).strict(); +export type AcceptanceBatch=z.infer; +const counts=z.object({passed:z.number().int().min(0).max(16),failed:z.number().int().min(0).max(16),incomplete:z.number().int().min(0).max(16),reused:z.number().int().min(0).max(16),inspected:z.number().int().min(0).max(16)}).strict(); +const limits=z.object({max_batches:z.literal(16),max_input_files:z.literal(10000),max_input_entries:z.literal(20000),max_input_bytes:z.literal(67108864),max_report_bytes:z.literal(1048576),max_reports_bytes:z.literal(8388608),max_package_bytes:z.literal(16777216)}).strict(); +export const batchReportSchema=z.object({schema:z.literal('skillsync.batch-report/v1'),createdAt:z.string().datetime(),tool_version:z.string().min(1).max(64), + declaration:declarationSchema,declaration_sha256:sha,previous_package_sha256:sha.optional(), + status:z.enum(['passed','failed','incomplete']),exitCode:z.union([z.literal(0),z.literal(1),z.literal(2)]),counts,limits, + evidence:z.literal('physical-files'),execution:z.literal('not-run'),provenance:z.literal('not-authenticated'),batches:z.array(caseSchema).min(1).max(16)}).strict(); +export type BatchReport=z.infer; +const packageSchema=z.object({schema:z.literal('skillsync.review-package/v1'),payload_sha256:sha,payload:batchReportSchema}).strict(); +export type ReviewPackage=z.infer; + +export class BatchAcceptanceError extends Error { + readonly exitCode=2; + constructor(readonly code:string,message:string){super(message);this.name='BatchAcceptanceError';} +} +export const sha256=(bytes:Uint8Array|string)=>createHash('sha256').update(bytes).digest('hex'); + +/** Shared canonical form; hashes detect integrity changes, not authorship. */ +export function canonicalJson(value:unknown):string { + let keys=0; + function encode(item:unknown,depth:number):string { + if(depth>64)throw new BatchAcceptanceError('batch.metadata-capacity','Batch metadata depth exceeded.'); + if(item===null||typeof item!=='object'){const result=JSON.stringify(item);if(result===undefined)throw new BatchAcceptanceError('batch.metadata-invalid','Invalid batch metadata.');return result;} + if(Array.isArray(item))return '['+item.map(child=>encode(child,depth+1)).join(',')+']'; + const record=item as Record,names=Object.keys(record).filter(key=>record[key]!==undefined).sort(); + keys+=names.length;if(keys>100000)throw new BatchAcceptanceError('batch.metadata-capacity','Batch metadata key budget exceeded.'); + return '{'+names.map(key=>JSON.stringify(key)+':'+encode(record[key],depth+1)).join(',')+'}'; + } + return encode(value,0); +} +export const contentHash=(value:unknown)=>sha256(canonicalJson(value)); + +export function batchStatus(report:ArtifactReport,source:string|undefined,declaration:AcceptanceDeclaration,batchKind:'artifacts'|'delivery'):AcceptanceBatch['status'] { + if(!source||report.exitCode===2||!report.artifact_set_sha256||report.findings.length>=256||report.comparison?.status==='unavailable'||report.reconciliations?.some(item=>item.status==='unknown'))return 'incomplete'; + if(report.status==='failed')return 'failed'; + if(!report.declarations||report.contract_sha256!==declaration.contract_sha256||batchKind==='delivery'&&report.receipt_binding!=='verified'|| + report.files.length!==declaration.file_paths.length||report.files.some(file=>!declaration.file_paths.includes(file.path)))return 'incomplete'; + return 'passed'; +} +export function batchCounts(batches:AcceptanceBatch[]):BatchReport['counts'] { + return {passed:batches.filter(item=>item.status==='passed').length,failed:batches.filter(item=>item.status==='failed').length, + incomplete:batches.filter(item=>item.status==='incomplete').length,reused:batches.filter(item=>item.mode==='reused').length,inspected:batches.filter(item=>item.mode==='inspected').length}; +} +export function summarizeBatch(batches:AcceptanceBatch[]):Pick { + const counts=batchCounts(batches);return {counts,status:counts.incomplete?'incomplete':counts.failed?'failed':'passed',exitCode:counts.incomplete?2:counts.failed?1:0}; +} + +export function validateBatchReport(input:unknown):BatchReport { + try{ + const payload=batchReportSchema.parse(input),expected=summarizeBatch(payload.batches); + if(contentHash(payload.declaration)!==payload.declaration_sha256||canonicalJson(expected.counts)!==canonicalJson(payload.counts)||payload.status!==expected.status||payload.exitCode!==expected.exitCode|| + new Set(payload.declaration.file_paths).size!==payload.declaration.file_paths.length||new Set(payload.batches.map(item=>item.id.toLowerCase())).size!==payload.batches.length||payload.batches.length!==payload.declaration.batches.length)throw Error(); + let reportBytes=0,sourceBytes=0,sourceFiles=0; + for(const[index,item]of payload.batches.entries()){ + const declared=payload.declaration.batches[index]; + if(item.id!==declared.id||item.kind!==declared.kind||contentHash(item.report)!==item.report_sha256|| + item.report.contract_sha256!==payload.declaration.contract_sha256||item.status!==batchStatus(item.report,item.source_sha256,payload.declaration,item.kind)|| + item.source_filessum+file.bytes,0)|| + item.mode==='reused'&&(item.status!=='passed'||!payload.previous_package_sha256))throw Error(); + const inventory=item.source_inventory; + if(inventory){ + if(!item.source_sha256||new Set(inventory.map(file=>file.path)).size!==inventory.length||inventory.length!==item.source_files||inventory.reduce((sum,file)=>sum+file.bytes,0)!==item.source_bytes||artifactSetHash(inventory)!==item.source_sha256)throw Error(); + const allowed=new Set(item.kind==='delivery'?['receipt.json','delivery.json',...payload.declaration.file_paths.map(path=>'artifacts/'+path)]:payload.declaration.file_paths); + if(inventory.some(file=>!allowed.has(file.path)))throw Error(); + const artifacts=inventory.filter(file=>item.kind!=='delivery'||file.path.startsWith('artifacts/')).map(file=>({...file,path:item.kind==='delivery'?file.path.slice(10):file.path})); + if(item.report.artifact_set_sha256&&artifactSetHash(artifacts)!==item.report.artifact_set_sha256||item.report.files.some(file=>!artifacts.some(input=>input.path===file.path&&input.sha256===file.sha256&&input.bytes===file.bytes)))throw Error(); + }else if(item.source_sha256||item.source_files||item.source_bytes)throw Error(); + const current=Buffer.byteLength(canonicalJson(item.report));if(current>BATCH_LIMITS.max_report_bytes)throw Error();reportBytes+=current; + sourceBytes+=item.source_bytes;sourceFiles+=item.source_files; + if(item.previous){ + const prior=Buffer.byteLength(canonicalJson(item.previous.report));if(prior>BATCH_LIMITS.max_report_bytes||contentHash(item.previous.report)!==item.previous.report_sha256||!payload.previous_package_sha256)throw Error();reportBytes+=prior; + const compared=compareArtifactReports(item.previous.report,item.report,item.previous.report_sha256); + if(!item.comparison||canonicalJson(compared)!==canonicalJson(item.comparison))throw Error(); + }else if(item.comparison)throw Error(); + } + if(reportBytes>BATCH_LIMITS.max_reports_bytes||sourceBytes>BATCH_LIMITS.max_input_bytes||sourceFiles>BATCH_LIMITS.max_input_files)throw Error(); + return payload; + }catch(error){if(error instanceof BatchAcceptanceError)throw error;throw new BatchAcceptanceError('batch.package-invalid','The review package is inconsistent or unsupported.');} +} +export function parseReviewPackage(input:unknown):ReviewPackage { + try{ + const packet=packageSchema.parse(input),payload=validateBatchReport(packet.payload); + if(contentHash(payload)!==packet.payload_sha256)throw Error(); + return {...packet,payload}; + }catch(error){if(error instanceof BatchAcceptanceError)throw error;throw new BatchAcceptanceError('batch.package-invalid','The review package integrity could not be verified.');} +} diff --git a/src/reporters/batch-html.ts b/src/reporters/batch-html.ts new file mode 100644 index 0000000..7f12c54 --- /dev/null +++ b/src/reporters/batch-html.ts @@ -0,0 +1,22 @@ +import {type BatchReport,canonicalJson,validateBatchReport} from "../domain/batch-report.js"; +import {renderArtifactsHtml} from "./artifacts-html.js"; + +const escape=(value:unknown)=>String(value).replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]!)); +const HTML_LIMIT=7*1024*1024; +/** Offline display is bounded independently of the complete JSON evidence. */ +export function renderBatchHtml(input:BatchReport):string { + const report=validateBatchReport(input);let used=0; + const rows=report.batches.map(item=>`${escape(item.id)}${escape(item.status)}${item.mode==='reused'?'Reused unchanged passed evidence':'Inspected current inputs'}${item.report.findings.length}`).join(''); + const sections=report.batches.map(item=>{ + let detail='

Full bounded evidence remains in the JSON review package. This report is above the offline detail display budget.

'; + if(Buffer.byteLength(canonicalJson(item.report))<=256*1024){ + const rendered=renderArtifactsHtml(item.report),size=Buffer.byteLength(rendered); + if(size<=512*1024&&used+size*6<5*1024*1024){used+=size*6;detail=`
Inspect declared checks and affected records
`;} + } + const comparison=item.comparison?.status==='compared'?`

Compared with the retained prior report: ${item.comparison.findings!.added} new, ${item.comparison.findings!.ongoing} ongoing, ${item.comparison.findings!.resolved} resolved finding categories.

`:item.comparison?`

Comparison unavailable: ${escape(item.comparison.reason)}. No improvement is inferred from incomplete evidence.

`:''; + return `

${escape(item.id)} · ${escape(item.status)}

${item.mode==='reused'?'Recorded reuse of unchanged passed inputs.':'Recorded inspection of the selected inputs.'} ${item.source_files} files · ${item.source_bytes} bytes.

${comparison}
Evidence bindings

Input snapshot: ${escape(item.source_sha256??'unavailable')}

Report: ${item.report_sha256}

${detail}
`; + }).join(''); + const html=`SkillSync Batch acceptance

Batch acceptance

${escape(report.status)} · ${report.counts.passed} passed · ${report.counts.failed} failed · ${report.counts.incomplete} incomplete.

Review-package integrity is verified. Original data is not rechecked by opening this page; authors are not authenticated. Results describe the recorded contract and input snapshots.

${escape(report.createdAt)} · SkillSync ${escape(report.tool_version)} · read-only validation · no Skill execution.

Plan: ${report.declaration.plan_sha256}

Contract: ${report.declaration.contract_sha256}

${rows}
BatchOutcomeEvidence sourceFindings
${sections}

Raw data rows, original absolute paths and scalar expected values are omitted. User-defined batch IDs, relative file names, column/check declarations and record coordinates may still be sensitive. Hashes establish integrity bindings, not trusted authorship or scientific/production acceptance. Detail display has an independent capacity budget.

`; + if(Buffer.byteLength(html)>HTML_LIMIT)throw new Error('Offline batch display exceeds its capacity.'); + return html; +} diff --git a/src/workbench/page.ts b/src/workbench/page.ts index fed3210..f7b9b20 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -2,18 +2,19 @@ export function workbenchPage(nonce: string): string { return `SkillSync 核验台

SkillSync 核验台

+
多批次验收与离线复核包

按计划只读核验多个交付;修正失败批次后保留已通过的证据。打开复核包只检查完整性,不重新核验原始数据或认证作者。

历史核验

选择历史记录作为基线,再核验当前文件。历史记录只读。

1. 选择目录

尚未核验

只检查本机文件,不运行 Skill。

报告保存位置

报告、计划和回执保存在本机私有目录;历史功能启用前的文件仍保留。

`; } -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:36px}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{overflow-wrap:anywhere}footer{font-size:13px;color:#496771}footer p{margin:8px 0}summary{cursor:pointer;min-height:44px;padding:8px 0}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}@media(max-width:580px){.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:36px}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}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}@media(max-width:580px){.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; +let busy = false, planId = null, selected = null, baselineId = null, historyId = null, historyOffset = 0, historyTotal = 0, historyLoading = false, historyRefreshQueued = false, renderedHistory = null, batchReady=false; async function api(action, body = {}) { let response; try { response = await fetch('/api/' + action, {method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body)}); } catch { throw new Error('无法连接本机服务,请确认核验台已启动后重试。'); } @@ -23,7 +24,7 @@ async function api(action, body = {}) { } function showReport(html){ $('report').srcdoc=html.replaceAll('href="#','href="about: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'].forEach(id => $(id).disabled=value); 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','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 clearPlan(){planId=null;$('confirm').checked=false;$('fix').hidden=true;$('changes').textContent='';$('apply').disabled=true;} function invalidate(){ selected=null; planId=null; baselineId=null; historyId=null; $('comparison').textContent=''; $('export-summary').disabled=true; $('result').hidden=true; $('fix').hidden=true; $('confirm').checked=false; $('report').srcdoc=''; status('目录或模式已更改,请重新核验'); } ['root','contract','kind','delivery'].forEach(id => $(id).addEventListener('change', () => {invalidate(); $('contract-fields').hidden=$('kind').value!=='artifacts';})); @@ -57,5 +58,12 @@ $('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(){clearPlan();selected=null;historyId=null;baselineId=null;$('plan').hidden=true;$('fix').hidden=true;$('export-summary').disabled=true;$('comparison').textContent='';$('result').hidden=true;$('report').srcdoc='';} +$('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){showReport(data.html);$('result').hidden=false;$('plan').hidden=true;$('fix').hidden=true;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('已读取有界验收证据;原始数据未在打开时重新核验,作者未认证。');} +$('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);}}; loadHistory(); `; diff --git a/src/workbench/server.ts b/src/workbench/server.ts index 809e046..dc53938 100644 --- a/src/workbench/server.ts +++ b/src/workbench/server.ts @@ -17,6 +17,9 @@ import { exportHistorySummary, MAX_OUTPUT, WorkbenchHistory, WorkbenchStateError import { parseStrictJson } from "../domain/strict-json.js"; import { compareIssues } from "../domain/baseline.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"; +import {BatchAcceptanceError} from "../domain/batch-report.js"; const identity = (s: {dev: number; ino: number}) => `${s.dev}:${s.ino}`; const inside = (root: string, target: string) => { const r = relative(root, target); return !r || (!r.startsWith("..") && !isAbsolute(r)); }; @@ -38,6 +41,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; let plan: { id: string; path: string; value: ActionPlan; snapshot: string; historyId:string } | undefined; let baseline: {id:string; report:VerificationReport|ArtifactReport; digest:string} | undefined; let latestId:string|undefined; + let batchPreview:{path:string;sha:string}|undefined; let origin = ""; async function checkSelection() { @@ -46,8 +50,8 @@ export async function startWorkbench(options: { port?: number; stateDir: string; if (!s.isDirectory() || s.isSymbolicLink() || identity(s) !== selection.identity || await realpath(selection.path) !== selection.path) throw new WorkbenchStateError("目录已被替换,请重新选择。"); return selection; } - async function command(args: string[]): Promise { - await history.capacity(); if (cancelled) throw new Error("Cancelled"); + async function command(args: string[], commandOptions:{reserveHistory?:boolean}={}): Promise { + if(commandOptions.reserveHistory!==false)await history.capacity(); if (cancelled) throw new Error("Cancelled"); return new Promise((accept, reject) => { const child = spawn(process.execPath, [cliPath, ...args], { shell: false, stdio: ["ignore", "pipe", "pipe"], env: { PATH: dirname(process.execPath), LANG: "en_US.UTF-8" } }); active = child; const chunks: Buffer[] = []; let size = 0, failure = ""; @@ -71,6 +75,12 @@ export async function startWorkbench(options: { port?: number; stateDir: string; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("请求无效。"); return value as Record; } + function batchPath(value:unknown):string { + if(typeof value!=="string"||!isAbsolute(value)||value.length>4096||/[\p{Cc}]/u.test(value))throw new WorkbenchStateError("请输入独立计划或复核包的完整文件路径。"); + const path=resolve(value),parent=dirname(path); + if(parent==="/"||parent===homedir()||inside(stateDir,path))throw new WorkbenchStateError("请选择独立项目目录中的文件,避免根目录、主目录和私有历史目录。"); + return path; + } function send(res: ServerResponse, code: number, value: unknown, type = "application/json") { res.writeHead(code, { "Content-Type": `${type}; charset=utf-8`, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' about:; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none'" }); res.end(type === "application/json" ? JSON.stringify(value) : value); @@ -96,7 +106,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; try { if (req.url === "/api/choose") { if (process.platform !== "darwin") throw new Error("请在路径框中输入目录或合同的完整路径。"); - const script = input.file === true ? 'POSIX path of (choose file with prompt "选择核验合同")' : 'POSIX path of (choose folder with prompt "选择核验目录")'; + const script = input.file === true ? 'POSIX path of (choose file with prompt "选择核验清单、计划或复核包")' : 'POSIX path of (choose folder with prompt "选择核验目录")'; try { const {stdout} = await promisify(execFile)("/usr/bin/osascript", ["-e", script], {timeout: 60_000, maxBuffer: 8192}); return send(res, 200, {path: stdout.trim()}); @@ -105,11 +115,32 @@ export async function startWorkbench(options: { port?: number; stateDir: string; throw new WorkbenchStateError(cancelled ? "已取消选择,原路径保留。" : "系统选择器未完成,请重试或在路径框输入完整路径。"); } } + if(req.url==="/api/batch/preview"||req.url==="/api/batch/run"||req.url==="/api/batch/open"){ + // Batch inspection never leaves a single-Skill mutation capability alive. + selection=undefined;plan=undefined;baseline=undefined;latestId=undefined;actionable=[]; + if(req.url.endsWith("preview")){ + batchPreview=undefined;const path=batchPath(input.path),data=await previewAcceptancePlan(path); + batchPreview={path,sha:data.plan_sha256};return send(res,200,data); + } + if(req.url.endsWith("open")){ + batchPreview=undefined;const path=batchPath(input.path); + const checked=await inspectAcceptancePackage(path,{expectSha256:typeof input.expectSha256==="string"&&input.expectSha256?input.expectSha256:undefined}); + return send(res,200,{integrity:"verified",summary:batchSummary(checked.packet.payload),package_sha256:checked.package_sha256,html:renderBatchHtml(checked.packet.payload)}); + } + const path=batchPath(input.path),chosen=batchPreview;batchPreview=undefined; + if(!chosen||chosen.path!==path)throw new WorkbenchStateError("请先预检当前验收计划,再执行多批次核验。"); + const output=batchPath(input.output),args=["batch","run","--plan",path,"--output",output,"--expect-plan-sha256",chosen.sha,"--format","json"]; + if(typeof input.previous==="string"&&input.previous){args.push("--previous",batchPath(input.previous));if(input.retryFailed===true)args.push("--retry-failed");} + const raw=await command(args,{reserveHistory:false}),result=parseStrictJson(raw) as {schema?:string;package_sha256?:string}; + if(result.schema!=="skillsync.batch-report/v1"||typeof result.package_sha256!=="string"||!/^[a-f0-9]{64}$/.test(result.package_sha256))throw new WorkbenchStateError("多批次结果未确认,请先检查输出文件后再重试。"); + const checked=await inspectAcceptancePackage(output,{expectSha256:result.package_sha256}); + return send(res,200,{integrity:"verified",summary:batchSummary(checked.packet.payload),package_sha256:checked.package_sha256,packagePath:output,html:renderBatchHtml(checked.packet.payload)}); + } if (req.url === "/api/history/open" || req.url === "/api/history/export") { const {record,output}=await history.read(input.id); if(req.url.endsWith("export"))return send(res,200,{html:exportHistorySummary(record)}); // Viewing old evidence grants no selection or mutation capability. - selection=undefined;plan=undefined;baseline=undefined;latestId=undefined;actionable=[]; + selection=undefined;plan=undefined;baseline=undefined;latestId=undefined;actionable=[];batchPreview=undefined; let html: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)); @@ -117,6 +148,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; 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}); } if (req.url === "/api/select") { + batchPreview=undefined; if (typeof input.path !== "string" || !isAbsolute(input.path) || input.path.length > 4096) throw new WorkbenchStateError("请输入完整目录路径。"); const path = resolve(input.path), s = await lstat(path); if (!s.isDirectory() || s.isSymbolicLink() || await realpath(path) !== path || path === "/" || path === homedir() || inside(path, stateDir) || inside(stateDir, path)) throw new WorkbenchStateError("请选择独立的真实 Skill 或交付目录。"); @@ -205,7 +237,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; } return send(res, 404, {error: "未知操作。"}); } finally { busy = false; applying = false; } - } catch (error) { send(res, 400, {error: cancelled ? "核验已取消,未产生完成结果。" : error instanceof WorkbenchStateError ? error.message : error instanceof Error && "code" in error && error.code === "ENOENT" ? "目录或文件不存在,请重新选择或检查历史材料。" : "操作失败,请检查所选目录和验收清单后重新核验。"}); } + } catch (error) { send(res, 400, {error: cancelled ? "核验已取消,先检查可能保留的输出,再决定后续操作。" : error instanceof WorkbenchStateError ? error.message : error instanceof BatchAcceptanceError ? error.message : error instanceof Error && "code" in error && error.code === "ENOENT" ? "目录或文件不存在,请重新选择或检查历史材料。" : "操作失败,请检查所选目录和验收清单后重新核验。"}); } }); server.requestTimeout = 130_000; server.headersTimeout = 10_000; server.maxConnections = 8; await new Promise((accept, reject) => { server.once("error", reject); server.listen(options.port ?? 14986, "127.0.0.1", accept); }); diff --git a/tests/integration/batch-acceptance.test.ts b/tests/integration/batch-acceptance.test.ts new file mode 100644 index 0000000..6216779 --- /dev/null +++ b/tests/integration/batch-acceptance.test.ts @@ -0,0 +1,91 @@ +import { cp, mkdir, mkdtemp, readFile, realpath, stat, symlink, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { runCli } from "../../src/cli/index.js"; +import {contentHash} from "../../src/domain/batch-report.js"; + +async function fixture() { + const root=await realpath(await mkdtemp(join(tmpdir(),"sync-batch-"))); + await cp(resolve('fixtures/product/tenant-reconciliation/contract.json'),join(root,'contract.json')); + await mkdir(join(root,'batches')); + for(const id of ['north','south']) await cp(resolve('fixtures/product/tenant-reconciliation/artifacts'),join(root,'batches',id),{recursive:true}); + await cp(resolve('fixtures/product/tenant-reconciliation/faults/shifted-allocations.csv'),join(root,'batches/south/allocations.csv')); + const plan={schema:'skillsync.acceptance-plan/v1',contract:'contract.json',batches:[{id:'north',path:'batches/north',kind:'artifacts'},{id:'south',path:'batches/south',kind:'artifacts'}]}; + await writeFile(join(root,'plan.json'),JSON.stringify(plan)); + return {root,plan,planPath:join(root,'plan.json'),output:join(root,'acceptance.json')}; +} +const execute=(plan:string,output:string,extra:string[]=[])=>runCli(['batch','run','--plan',plan,'--output',output,'--format','json',...extra]); + +describe('portable multi-batch acceptance',()=>{ + it('records per-batch failures without copying business rows, and can verify the offline package',async()=>{ + const {root,planPath,output}=await fixture();const run=await execute(planPath,output); + expect(run.stdout).toContain('skillsync.batch-report/v1');expect(run.exitCode).toBe(1); + const saved=JSON.parse(await readFile(output,'utf8'));expect(saved.payload.status).toBe('failed'); + expect(saved.payload.batches.map((item:{status:string})=>item.status)).toEqual(['passed','failed']); + for(const value of [root,'ORG_A','ORG_B','ORDER_1'])expect(JSON.stringify(saved)).not.toContain(value); + const checked=await runCli(['batch','inspect','--package',output,'--format','json']);expect(checked.exitCode).toBe(0);expect(JSON.parse(checked.stdout).integrity).toBe('verified'); + const html=await runCli(['batch','inspect','--package',output,'--format','html']);expect(html.exitCode).toBe(0);expect(html.stdout).toContain('Batch acceptance');expect(html.stdout).toContain('not authenticated'); + expect((await stat(output)).mode&0o777).toBe(0o600); + }); + + it('rechecks a repaired batch while preserving a passed report and the original package',async()=>{ + const {root,planPath,output}=await fixture();await execute(planPath,output);const original=await readFile(output);const before=JSON.parse(original.toString()); + await cp(join(root,'batches/north/allocations.csv'),join(root,'batches/south/allocations.csv')); + const next=join(root,'corrected.json');const run=await execute(planPath,next,['--previous',output,'--retry-failed']);expect(run.exitCode).toBe(0); + const after=JSON.parse(await readFile(next,'utf8'));expect(after.payload.status).toBe('passed'); + expect(after.payload.batches.map((item:{mode:string})=>item.mode)).toEqual(['reused','inspected']); + expect(after.payload.batches[0].report_sha256).toBe(before.payload.batches[0].report_sha256); + expect(after.payload.batches[1].comparison.status).toBe('compared');expect(after.payload.batches[1].comparison.findings.resolved).toBeGreaterThan(0); + expect(await readFile(output)).toEqual(original); + }); + + it('refuses reuse after a passed input or contract changes and does not publish a replacement',async()=>{ + for(const changed of ['input','contract']){ + const {root,planPath,output}=await fixture();await execute(planPath,output);const original=await readFile(output); + const path=changed==='input'?join(root,'batches/north/summary.json'):join(root,'contract.json');await writeFile(path,(await readFile(path,'utf8'))+'\n'); + const next=join(root,'must-not-publish.json');const run=await execute(planPath,next,['--previous',output,'--retry-failed']);expect(run.exitCode).toBe(2); + await expect(stat(next)).rejects.toMatchObject({code:'ENOENT'});expect(await readFile(output)).toEqual(original);expect(run.stderr).not.toContain(root); + } + }); + + it('rejects package tampering, output collisions and duplicate plan identities',async()=>{ + const {root,plan,planPath,output}=await fixture();await execute(planPath,output);const original=await readFile(output); + expect((await execute(planPath,output)).exitCode).toBe(2);expect(await readFile(output)).toEqual(original); + const damaged=JSON.parse(original.toString());damaged.payload.status='passed';const path=join(root,'damaged.json');await writeFile(path,JSON.stringify(damaged)); + expect((await runCli(['batch','inspect','--package',path,'--format','json'])).exitCode).toBe(2); + plan.batches[1].id=plan.batches[0].id;await writeFile(planPath,JSON.stringify(plan));expect((await execute(planPath,join(root,'duplicate.json'))).exitCode).toBe(2); + }); + + it('rejects symbolic package files without following them',async()=>{ + if(process.platform==='win32')return; + const {root,planPath,output}=await fixture();await execute(planPath,output);const link=join(root,'linked.json');await symlink(output,link); + expect((await runCli(['batch','inspect','--package',link,'--format','json'])).exitCode).toBe(2); + }); + + it('checks source/report bindings even when an edited packet has a new outer hash',async()=>{ + const {root,planPath,output}=await fixture();await execute(planPath,output); + const packet=JSON.parse(await readFile(output,'utf8')); + packet.payload.batches[0].source_inventory[0].sha256='0'.repeat(64); + packet.payload_sha256=contentHash(packet.payload); + const damaged=join(root,'rebuilt-hash.json');await writeFile(damaged,JSON.stringify(packet)); + expect((await runCli(['batch','inspect','--package',damaged])).exitCode).toBe(2); + }); + + it('binds the optional contract and accepts a BOM without changing the frozen plan hash',async()=>{ + const {root,planPath,output}=await fixture();await writeFile(planPath,'\uFEFF'+await readFile(planPath,'utf8')); + expect((await execute(planPath,output)).exitCode).toBe(1); + expect((await runCli(['batch','inspect','--package',output,'--contract',join(root,'contract.json')])).exitCode).toBe(0); + await writeFile(join(root,'contract.json'),(await readFile(join(root,'contract.json'),'utf8'))+'\n'); + expect((await runCli(['batch','inspect','--package',output,'--contract',join(root,'contract.json')])).exitCode).toBe(2); + }); + + it('retains the exact plan binding without publishing paths and refuses swapped source declarations',async()=>{ + const {root,plan,planPath,output}=await fixture();await execute(planPath,output); + expect((await runCli(['batch','inspect','--package',output,'--plan',planPath])).exitCode).toBe(0); + [plan.batches[0].path,plan.batches[1].path]=[plan.batches[1].path,plan.batches[0].path];await writeFile(planPath,JSON.stringify(plan)); + expect((await runCli(['batch','inspect','--package',output,'--plan',planPath])).exitCode).toBe(2); + const next=join(root,'swapped.json');expect((await execute(planPath,next,['--previous',output,'--retry-failed'])).exitCode).toBe(2); + await expect(stat(next)).rejects.toMatchObject({code:'ENOENT'}); + }); +}); diff --git a/tests/workbench/batch.test.ts b/tests/workbench/batch.test.ts new file mode 100644 index 0000000..535fbad --- /dev/null +++ b/tests/workbench/batch.test.ts @@ -0,0 +1,37 @@ +import {cp,mkdir,mkdtemp,readFile,realpath,writeFile} from 'node:fs/promises'; +import {join,resolve} from 'node:path'; +import {tmpdir} from 'node:os'; +import {afterEach,expect,it} from 'vitest'; +import {startWorkbench} from '../../src/workbench/server.js'; +const apps:Array>>=[]; +afterEach(async()=>{for(const app of apps.splice(0))await app.close();}); + +it('previews then creates and reopens a batch package without enabling Skill repair',async()=>{ + const base=await realpath(await mkdtemp(join(tmpdir(),'sync-batch-gui-'))),data=join(base,'inputs');await mkdir(data); + await cp(resolve('fixtures/product/tenant-reconciliation'),join(data,'north'),{recursive:true}); + await cp(join(data,'north/artifacts'),join(data,'south'),{recursive:true}); + await cp(join(data,'north/faults/shifted-allocations.csv'),join(data,'south/allocations.csv')); + const path=join(data,'plan.json');await writeFile(path,JSON.stringify({schema:'skillsync.acceptance-plan/v1',contract:'north/contract.json',batches:[{id:'north',kind:'artifacts',path:'north/artifacts'},{id:'south',kind:'artifacts',path:'south'}]})); + const app=await startWorkbench({port:0,stateDir:join(base,'state'),cliPath:resolve('dist/cli/index.js')});apps.push(app); + const page=await(await fetch(app.url)).text(),nonce=page.match(/name="client" content="([a-f0-9]+)"/)![1]; + const post=async(action:string,body={})=>{const response=await fetch(app.url+'/api/'+action,{method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body)});return {status:response.status,data:await response.json()};}; + const preview=await post('batch/preview',{path});expect(preview.status).toBe(200);expect(preview.data.batches).toHaveLength(2); + const output=join(base,'first.json');const result=await post('batch/run',{path,output});expect(result.status).toBe(200);expect(result.data.summary.status).toBe('failed');expect(result.data.html).toContain('Batch acceptance'); + const saved=await readFile(output); + expect((await post('apply',{id:'old',confirm:true})).status).toBe(400); + const reopened=await post('batch/open',{path:output,expectSha256:result.data.package_sha256});expect(reopened.status).toBe(200);expect(reopened.data.integrity).toBe('verified'); + expect(await readFile(output)).toEqual(saved); + for(const secret of [base,'ORG_A','ORDER_1'])expect(result.data.html).not.toContain(secret); +}); + +it('a plan changed after preview cannot create a package',async()=>{ + const base=await realpath(await mkdtemp(join(tmpdir(),'sync-batch-stale-'))),data=join(base,'data');await mkdir(data); + await cp(resolve('fixtures/product/tenant-reconciliation'),data,{recursive:true}); + const path=join(data,'plan.json'),plan={schema:'skillsync.acceptance-plan/v1',contract:'contract.json',batches:[{id:'one',kind:'artifacts',path:'artifacts'}]};await writeFile(path,JSON.stringify(plan)); + const app=await startWorkbench({port:0,stateDir:join(base,'state'),cliPath:resolve('dist/cli/index.js')});apps.push(app); + const nonce=(await(await fetch(app.url)).text()).match(/name="client" content="([a-f0-9]+)"/)![1]; + const post=async(action:string,body={})=>{const response=await fetch(app.url+'/api/'+action,{method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body)});return response;}; + expect((await post('batch/preview',{path})).status).toBe(200);await writeFile(path,JSON.stringify(plan)+'\n'); + expect((await post('batch/run',{path,output:join(base,'blocked.json')})).status).toBe(400); + await expect(readFile(join(base,'blocked.json'))).rejects.toMatchObject({code:'ENOENT'}); +});