@feng0531/dsh-code-review 0.85.11 → 0.86.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.
@@ -0,0 +1,117 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { mkdir, open, lstat, realpath, rename, unlink, chmod } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ const queues = new Map();
7
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
8
+ const STATUSES = new Set(['completed', 'failed', 'cancelled', 'incomplete', 'validated-draft', 'rejected']);
9
+ const fail = code => Object.assign(new Error(code), { code });
10
+ const within = (base, name) => { const r = path.relative(base, name); return r === '' || (!path.isAbsolute(r) && r !== '..' && !r.startsWith('..' + path.sep)); };
11
+ function ownerIdentity(owner) {
12
+ if (!owner || !['sessionId', 'cwd', 'provider', 'model'].every(key => typeof owner[key] === 'string' && owner[key].length > 0 && owner[key].length <= 4096) || !path.isAbsolute(owner.cwd) || !(owner.reasoningEffort == null || typeof owner.reasoningEffort === 'string' && owner.reasoningEffort.length <= 256)) throw fail('INVALID_REVIEW_ARTIFACT_OWNER');
13
+ return { sessionId: owner.sessionId, cwd: owner.cwd, provider: owner.provider, model: owner.model, reasoningEffort: owner.reasoningEffort ?? null };
14
+ }
15
+ function snapshot(value, depth = 0) {
16
+ if (depth > 64) throw fail('INVALID_REVIEW_ARTIFACT');
17
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
18
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
19
+ if (typeof value !== 'object' || ![Object.prototype, Array.prototype, null].includes(Object.getPrototypeOf(value))) throw fail('INVALID_REVIEW_ARTIFACT');
20
+ const result = Array.isArray(value) ? [] : Object.create(null);
21
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
22
+ if (Array.isArray(value) && key === 'length') continue;
23
+ if (!descriptor.enumerable) continue;
24
+ if (!('value' in descriptor) || descriptor.value === undefined) throw fail('INVALID_REVIEW_ARTIFACT');
25
+ Object.defineProperty(result, key, { value: snapshot(descriptor.value, depth + 1), enumerable: true, configurable: true, writable: true });
26
+ }
27
+ return result;
28
+ }
29
+ async function projectedRealpath(name) {
30
+ let current = path.resolve(name); const suffix = [];
31
+ for (;;) {
32
+ try { return path.join(await realpath(current), ...suffix); }
33
+ catch (error) {
34
+ if (error.code !== 'ENOENT') throw error;
35
+ try { if ((await lstat(current)).isSymbolicLink()) throw fail('UNSAFE_REVIEW_ARTIFACT'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
36
+ const parent = path.dirname(current); if (parent === current) throw error;
37
+ suffix.unshift(path.basename(current)); current = parent;
38
+ }
39
+ }
40
+ }
41
+
42
+ // Artifacts are untrusted private evidence, never approved source or confirmed findings.
43
+ export function createReviewArtifacts({ directory, maxArtifactBytes = 1048576, maxJobBytes = 8388608, maxArtifacts = 64 } = {}) {
44
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw fail('INVALID_REVIEW_ARTIFACT_DIRECTORY');
45
+ if (![maxArtifactBytes, maxJobBytes, maxArtifacts].every(n => Number.isSafeInteger(n) && n > 0) || maxArtifactBytes > 16777216 || maxJobBytes > 268435456 || maxArtifacts > 10000) throw fail('INVALID_REVIEW_ARTIFACT_LIMIT');
46
+ let canonical;
47
+ async function safeDirectory(owner) {
48
+ const [workspace, resolved] = await Promise.all([projectedRealpath(owner.cwd), projectedRealpath(directory)]);
49
+ if (within(workspace, resolved) || canonical !== undefined && canonical !== resolved) throw fail('UNSAFE_REVIEW_ARTIFACT');
50
+ await mkdir(resolved, { recursive: true, mode: 0o700 });
51
+ const stat = await lstat(directory);
52
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(directory) !== resolved) throw fail('UNSAFE_REVIEW_ARTIFACT');
53
+ await chmod(resolved, 0o700); canonical ??= resolved;
54
+ return resolved;
55
+ }
56
+ async function load(filename, jobId, owner) {
57
+ let handle;
58
+ try {
59
+ const stat = await lstat(filename);
60
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maxJobBytes + maxArtifacts * 1024 + 65536) throw fail('UNSAFE_REVIEW_ARTIFACT');
61
+ handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
62
+ const actual = await handle.stat();
63
+ if (!actual.isFile() || stat.ino !== actual.ino || stat.dev !== actual.dev) throw fail('UNSAFE_REVIEW_ARTIFACT');
64
+ const record = JSON.parse(await handle.readFile('utf8'));
65
+ if (record.jobId !== jobId || JSON.stringify(record.owner) !== JSON.stringify(owner)) throw fail('FOREIGN_REVIEW_ARTIFACT');
66
+ if (!Array.isArray(record.artifacts) || record.artifacts.length > maxArtifacts || !Number.isSafeInteger(record.retainedBytes) || record.retainedBytes < 0 || record.retainedBytes > maxJobBytes) throw fail('UNSAFE_REVIEW_ARTIFACT');
67
+ return record;
68
+ } catch (error) { if (error.code === 'ENOENT') return { jobId, owner, retainedBytes: 0, artifacts: [] }; throw error; }
69
+ finally { await handle?.close(); }
70
+ }
71
+ async function transaction(jobId, owner, work) {
72
+ if (typeof jobId !== 'string' || !UUID.test(jobId)) throw fail('INVALID_REVIEW_ARTIFACT_JOB');
73
+ try {
74
+ const root = await safeDirectory(owner), filename = path.join(root, jobId + '.json');
75
+ const previous = queues.get(filename) ?? Promise.resolve();
76
+ const next = previous.catch(() => {}).then(async () => {
77
+ await safeDirectory(owner);
78
+ const record = await load(filename, jobId, owner);
79
+ return work(record, filename);
80
+ });
81
+ queues.set(filename, next);
82
+ try { return await next; } finally { if (queues.get(filename) === next) queues.delete(filename); }
83
+ } catch (error) {
84
+ if (['INVALID_REVIEW_ARTIFACT_JOB', 'UNSAFE_REVIEW_ARTIFACT', 'FOREIGN_REVIEW_ARTIFACT'].includes(error.code)) throw fail(error.code);
85
+ throw fail('ARTIFACT_STORE_FAILED');
86
+ }
87
+ }
88
+ return {
89
+ async record(jobId, owner, artifact) {
90
+ const who = ownerIdentity(owner), value = snapshot(artifact);
91
+ if (!value || Array.isArray(value) || typeof value !== 'object') throw fail('INVALID_REVIEW_ARTIFACT');
92
+ const serialized = JSON.stringify(value), bytes = Buffer.byteLength(serialized);
93
+ const summary = { artifactId: randomUUID(), hash: createHash('sha256').update(serialized).digest('hex'), bytes, status: STATUSES.has(value.status) ? value.status : 'unclassified', omitted: false, recorded: true };
94
+ return transaction(jobId, who, async (record, filename) => {
95
+ if (bytes > maxArtifactBytes || record.retainedBytes + bytes > maxJobBytes || record.artifacts.length >= maxArtifacts) {
96
+ summary.omitted = true; summary.diagnostic = 'REVIEW_ARTIFACT_LIMIT_EXCEEDED';
97
+ if (record.artifacts.length >= maxArtifacts) return { ...summary, recorded: false };
98
+ }
99
+ record.artifacts.push({ ...summary, ...(summary.omitted ? {} : { artifact: value }) });
100
+ if (!summary.omitted) record.retainedBytes += bytes;
101
+ const tmp = path.join(canonical, jobId + '.' + randomUUID() + '.tmp'); let handle;
102
+ try {
103
+ handle = await open(tmp, 'wx', 0o600); await handle.writeFile(JSON.stringify(record)); await handle.sync(); await handle.close(); handle = undefined;
104
+ await safeDirectory(who);
105
+ try { const stat = await lstat(filename); if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) throw fail('UNSAFE_REVIEW_ARTIFACT'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
106
+ await rename(tmp, filename);
107
+ } finally { await handle?.close(); await unlink(tmp).catch(() => {}); }
108
+ return { ...summary };
109
+ });
110
+ },
111
+ async read(jobId, owner, { offset = 0, limit = 20 } = {}) {
112
+ const who = ownerIdentity(owner);
113
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw fail('INVALID_REVIEW_ARTIFACT_PAGE');
114
+ return transaction(jobId, who, async record => ({ jobId, totalArtifacts: record.artifacts.length, offset, limit, artifacts: record.artifacts.slice(offset, offset + limit) }));
115
+ },
116
+ };
117
+ }
@@ -1,13 +1,20 @@
1
1
  import { REVIEW_RUNTIME } from './runtime-identity.mjs';
2
- const PHASES = ['business-grouping', 'risk-planning', 'primary', 'file-interaction', 'window-synthesis', 'verification'];
3
- const PURPOSES = ['primary-initial', 'retrieval-followup', 'budget-finalization', 'json-correction', 'business-grouping', 'risk-planning', 'window-synthesis', 'file-interaction', 'verification'];
2
+ const PHASES = ['business-grouping', 'risk-planning', 'primary', 'group-primary', 'file-interaction', 'window-synthesis', 'verification'];
3
+ const PURPOSES = ['primary-initial', 'retrieval-followup', 'budget-finalization', 'json-correction', 'business-grouping', 'risk-planning', 'group-primary', 'window-synthesis', 'file-interaction', 'verification'];
4
4
  const READER_FIELDS = ['calls', 'outputBytes', 'maxCalls', 'maxOutputBytes', 'remainingCalls', 'remainingOutputBytes', 'maxResultBytes'];
5
+ const JSON_ISSUES = ['property-name', 'colon', 'separator', 'invalid-escape', 'control-character', 'unterminated-string', 'unexpected-end', 'trailing-content', 'syntax'];
6
+ const CORRECTION_CODES = ['MODEL_INVALID_JSON', 'MODEL_INVALID_REVIEW_RESPONSE', 'MODEL_INVALID_FINDING_IDENTITY', 'MODEL_INVALID_FINDING_SEVERITY', 'MODEL_MISSING_FINDING_FIELD', 'MODEL_INVALID_ANCHOR', 'MODEL_ANCHOR_MISMATCH', 'RETRIEVAL_PROTOCOL_ERROR'];
7
+ const LIMIT_CODES = ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED'];
8
+ const RECOVERY_KINDS = ['native-tool', 'text-compatible', 'trailing-prose'];
9
+ const FALLBACK_REASONS = ['mode-notauto', 'smallchange', 'disabled', 'project', 'laterround', 'multi-batch', 'incomplete-edit-scope', 'input-budget'];
5
10
  const number = n => Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
6
11
  // Source-free counters only; call walls overlap and include lifecycle observation.
7
12
  export function reviewDiagnostics(report, { reviewWallMs = null, executionWallMs = null } = {}) {
13
+ // Shared orchestration is one aggregator, not copied into each file's work.
14
+ const sources = [...(report.files ?? []), ...(report.orchestration ? [report.orchestration] : [])];
8
15
  const phases = [];
9
16
  for (const phase of PHASES) {
10
- const rows = (report.files ?? []).map(f => f.modelMetrics?.[phase]).filter(Boolean);
17
+ const rows = sources.map(f => f.modelMetrics?.[phase]).filter(Boolean);
11
18
  if (!rows.length) continue;
12
19
  const sum = field => rows.every(r => number(r[field]) !== null) ? number(rows.reduce((n, r) => n + r[field], 0)) : null;
13
20
  phases.push({ phase, calls: sum('calls'), observedCallWallSumMs: sum('durationMs'), inputBytes: sum('inputBytes'), totalOutputBytes: sum('totalOutputBytes') });
@@ -15,14 +22,32 @@ export function reviewDiagnostics(report, { reviewWallMs = null, executionWallMs
15
22
  const plans = (report.files ?? []).flatMap(f => f.riskPlans ?? []);
16
23
  const observedModelCalls = phases.length && phases.every(p => p.calls !== null) ? phases.reduce((n, p) => n + p.calls, 0) : null;
17
24
  const modelCallPurposes = PURPOSES.flatMap(purpose => {
18
- const values = (report.files ?? []).map(f => f.modelCallPurposes?.[purpose]).filter(value => value !== undefined);
25
+ const values = sources.map(f => f.modelCallPurposes?.[purpose]).filter(value => value !== undefined);
19
26
  if (!values.length) return [];
20
27
  return [{ purpose, calls: values.every(value => number(value) !== null) ? number(values.reduce((sum, value) => sum + value, 0)) : null }];
21
28
  });
22
29
  const readerBudget = Object.fromEntries(READER_FIELDS.map(field => [field, number(report.readerBudget?.[field])]));
23
- return { schemaVersion: 2, pluginVersion: REVIEW_RUNTIME.version, reviewWallMs: number(reviewWallMs), executionWallMs: number(executionWallMs),
24
- modelCalls: number(report.modelCalls), observedModelCalls, phases, modelCallPurposes, readerBudget,
30
+ const correctionCounts = new Map();
31
+ for (const event of sources.flatMap(f => f.modelCorrections ?? [])) {
32
+ if (!['explicit-json-correction', 'retrieval-protocol-correction', 'review-format-correction'].includes(event?.action) || !CORRECTION_CODES.includes(event.code)) continue;
33
+ const jsonIssue = JSON_ISSUES.includes(event.jsonIssue) ? event.jsonIssue : null;
34
+ const key = JSON.stringify([event.code, jsonIssue]);
35
+ correctionCounts.set(key, (correctionCounts.get(key) ?? 0) + 1);
36
+ }
37
+ const correctionReasons = [...correctionCounts].map(([key, calls]) => { const [code, jsonIssue] = JSON.parse(key); return { code, jsonIssue, calls }; }).sort((a, b) => a.code.localeCompare(b.code) || String(a.jsonIssue).localeCompare(String(b.jsonIssue)));
38
+ const limits = sources.flatMap(f => f.retrievalLimits ?? []);
39
+ const readerLimitReasons = LIMIT_CODES.flatMap(code => { const calls = limits.filter(row => row?.code === code).length; return calls ? [{ code, calls }] : []; });
40
+ const readerLimits = { maxRounds: number(report.readerLimits?.maxRounds ?? report.maxRetrievalRounds) };
41
+ const riskPlanningFallbackReasons = FALLBACK_REASONS.flatMap(reason => { const calls = (report.files ?? []).filter(f => f.riskPlanningFallbackReason === reason).length; return calls ? [{ reason, calls }] : []; });
42
+ const recoveryEvents = sources.flatMap(source => source.modelRecoveries ?? []);
43
+ const transportRecoveries = RECOVERY_KINDS.flatMap(kind => { const calls = recoveryEvents.filter(event => event?.action === 'json-transport-recovery' && event.kind === kind).length; return calls ? [{ kind, calls }] : []; });
44
+ const resume = { reusedFiles: number(report.resume?.reusedFiles) };
45
+ const artifactRecords = Array.isArray(report.artifacts) ? report.artifacts : null;
46
+ const artifacts = { retained: artifactRecords ? artifactRecords.filter(record => record?.omitted === false && record.recorded === true).length : null, omitted: artifactRecords ? artifactRecords.filter(record => record?.omitted === true).length : null };
47
+ return { schemaVersion: 2, resume, artifacts, transportRecoveries, pluginVersion: REVIEW_RUNTIME.version, reviewWallMs: number(reviewWallMs), executionWallMs: number(executionWallMs),
48
+ modelCalls: number(report.modelCalls), observedModelCalls, phases, modelCallPurposes, readerBudget, correctionReasons, readerLimitReasons, readerLimits, riskPlanningFallbackReasons,
25
49
  planning: { completed: plans.filter(p => p.status === 'completed' && p.method !== 'inline').length, inline: plans.filter(p => p.method === 'inline' && p.status === 'completed').length, inlineAbsent: plans.filter(p => p.method === 'inline' && p.status === 'absent').length, inlineInvalid: plans.filter(p => p.method === 'inline' && p.status === 'invalid').length, fallback: plans.filter(p => p.status === 'fallback').length, skipped: plans.filter(p => p.status === 'skipped').length },
50
+ callPurposeNotice: 'primary-initial is the first call of a retrieval-enabled stage, not only primary review; phase identifies the stage. Correction reason counts include actual correction invocation records only, not both failures and completion notices.',
26
51
  timingNotice: 'Observed call wall sums overlap under concurrency and include lifecycle observation; not pure provider latency. Missing values are unavailable, not zero.' };
27
52
  }
28
53
  export function diagnosticsMarkdown(d) {
@@ -35,13 +60,21 @@ export function diagnosticsMarkdown(d) {
35
60
  output.push('', '累计调用墙钟可能并发重叠,且包含生命周期观察/初始检查点等待,不是纯提供方耗时,也不能从总耗时中相减当作本地开销。');
36
61
  } else output.push('- 逐阶段模型观测不可用(未产生或旧报告未记录),不按零耗时解释。');
37
62
  if (d.modelCallPurposes?.length) {
38
- const labels = { 'primary-initial': '阶段初始', 'retrieval-followup': '检索后续', 'budget-finalization': '预算受限最终答复', 'json-correction': 'JSON纠正', 'business-grouping': '业务分组', 'risk-planning': '风险计划', 'window-synthesis': '窗口综合', 'file-interaction': '跨文件综合', verification: '复核' };
63
+ const labels = { 'primary-initial': '阶段初始', 'retrieval-followup': '检索后续', 'budget-finalization': '预算受限最终答复', 'json-correction': 'JSON纠正', 'business-grouping': '业务分组', 'risk-planning': '风险计划', 'group-primary': '分组主审', 'window-synthesis': '窗口综合', 'file-interaction': '跨文件综合', verification: '复核' };
39
64
  output.push('- 调用目的:' + d.modelCallPurposes.filter(row => PURPOSES.includes(row.purpose)).map(row => labels[row.purpose] + ' ' + (number(row.calls) ?? '不可用')).join(';') + '。');
40
65
  }
41
66
  if (d.readerBudget && READER_FIELDS.some(field => number(d.readerBudget[field]) !== null)) {
42
67
  const n = field => number(d.readerBudget[field]) ?? '不可用';
43
68
  output.push('- 共享读取预算:调用 ' + n('calls') + '/' + n('maxCalls') + ',序列化输出字节 ' + n('outputBytes') + '/' + n('maxOutputBytes') + ';剩余调用 ' + n('remainingCalls') + ',剩余输出字节 ' + n('remainingOutputBytes') + '。');
44
69
  }
70
+ if (d.correctionReasons?.length) output.push('- 纠正原因(仅实际纠正调用,不重复计失败/完成通知):' + d.correctionReasons.filter(r => CORRECTION_CODES.includes(r.code)).map(r => r.code + (JSON_ISSUES.includes(r.jsonIssue) ? '/' + r.jsonIssue : '') + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
71
+ if (d.readerLimitReasons?.length) output.push('- 读取限制原因:' + d.readerLimitReasons.filter(r => LIMIT_CODES.includes(r.code)).map(r => r.code + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
72
+ if (number(d.readerLimits?.maxRounds) !== null) output.push('- 每阶段最大检索轮次:' + number(d.readerLimits.maxRounds) + ';调用数/输出字节耗尽与轮次耗尽分别记录,不能混同。');
73
+ if (d.riskPlanningFallbackReasons?.length) output.push('- 风险策略回退原因:' + d.riskPlanningFallbackReasons.filter(r => FALLBACK_REASONS.includes(r.reason)).map(r => r.reason + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
74
+ if (d.transportRecoveries?.length) output.push('- 本地结果传输:' + d.transportRecoveries.filter(row => RECOVERY_KINDS.includes(row.kind)).map(row => row.kind + ' ' + (number(row.calls) ?? '不可用')).join(';') + ';仅计成功解析的传输事件,不表示额外模型调用或缺陷确认。');
75
+ if (number(d.resume?.reusedFiles) !== null) output.push('- 续审复用已完成文件:' + number(d.resume.reusedFiles) + ';复用成果不计为本次模型调用或新读取证据。');
76
+ if (number(d.artifacts?.retained) !== null || number(d.artifacts?.omitted) !== null) output.push('- 私有模型工件:已留存 ' + (number(d.artifacts.retained) ?? '不可用') + ',未留存 ' + (number(d.artifacts.omitted) ?? '不可用') + ';诊断仅含计数,不包含正文或访问标识。');
77
+ output.push('- primary-initial 表示启用检索的某阶段初始调用,不仅是主审;具体阶段以阶段统计为准。');
45
78
  output.push('- 风险计划:完成 ' + d.planning.completed + ',回退 ' + d.planning.fallback + ',跳过 ' + d.planning.skipped + ';合并主审问题 ' + (d.planning.inline ?? 0) + ',缺失 ' + (d.planning.inlineAbsent ?? 0) + ',无效 ' + (d.planning.inlineInvalid ?? 0) + '(非独立规划)。');
46
79
  return output;
47
80
  }
@@ -1,3 +1,4 @@
1
+ import {createReviewArtifacts} from './review-artifacts.mjs';
1
2
  import {jobProgress} from './job-progress.mjs';
2
3
  import {recoveryCoverage} from './recovery-coverage.mjs';
3
4
  import {randomUUID} from 'node:crypto';
@@ -7,6 +8,7 @@ import path from 'node:path';
7
8
  // Report storage is host-owned, never inside a reviewed working copy.
8
9
  export function createReviewJobs({directory}={}){
9
10
  if(typeof directory!=='string'||!path.isAbsolute(directory))throw new Error('Absolute host report directory required');
11
+ const artifacts=createReviewArtifacts({directory:path.join(directory,'artifacts')});
10
12
  const jobs=new Map(),listeners=new Map();const changed=id=>{for(const fn of [...(listeners.get(id)??[])])fn();};let disposed=false,canonicalDirectory;
11
13
  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};};
12
14
  const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
@@ -59,7 +61,7 @@ export function createReviewJobs({directory}={}){
59
61
  controller.signal.throwIfAborted();
60
62
  job.record.status='running';await enqueue(()=>save(job));
61
63
  controller.signal.throwIfAborted();
62
- 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);})});
64
+ const result=await work({signal:controller.signal,artifact:async value=>{const ref=await artifacts.record(id,job.record.owner,value);await enqueue(async()=>{(job.record.artifacts??=[]).push(ref);await save(job);});return ref;},saveResumeState:async value=>enqueue(async()=>{job.record.resumeState=structuredClone(value);await save(job);}),checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});
63
65
  job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';
64
66
  }catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}
65
67
  finally{
@@ -74,7 +76,9 @@ export function createReviewJobs({directory}={}){
74
76
  await startup;
75
77
  return {jobId:id,status:job.record.status,waitRevision:job.savedRevision,progress:jobProgress(job.record),nextAction:'Call code_review_wait with waitFor=terminal until terminal status; reuse the returned waitRevision as after without guessing increments. No code_review_status call is needed after each event. Do not finish the user request at start.',notice:'Background job accepted; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
76
78
  },
77
- async status(id,exec,page){const r=structuredClone(await owned(id,exec)),active=jobs.get(id);if(active&&!active.settled&&!['queued','running'].includes(r.status)){r.status='running';r.stage='finalizing';delete r.result;delete r.finishedAt;}r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=jobs.get(id)?.savedRevision??r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait with waitFor=terminal; reuse the exact returned waitRevision as after, never guess increments. No code_review_status call is needed after each event. Do not end the user 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,waitRevision:r.waitRevision,progress:r.progress,nextAction:r.nextAction,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,diagnostics:report.diagnostics,execution:report.execution,modelCalls:report.modelCalls}:({...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}: {})};},
79
+ async resume(id,exec){const r=await owned(id,exec);if(jobs.get(id)&&!jobs.get(id).settled||['queued','running'].includes(r.status)||!r.resumeState)throw new Error('No terminal completed-chain resume state available');return structuredClone(r.resumeState);},
80
+ async artifacts(id,exec,page){const r=await owned(id,exec);return artifacts.read(id,r.owner,page);},
81
+ async status(id,exec,page){const r=structuredClone(await owned(id,exec));delete r.resumeState;const active=jobs.get(id);if(active&&!active.settled&&!['queued','running'].includes(r.status)){r.status='running';r.stage='finalizing';delete r.result;delete r.finishedAt;}r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=jobs.get(id)?.savedRevision??r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait with waitFor=terminal; reuse the exact returned waitRevision as after, never guess increments. No code_review_status call is needed after each event. Do not end the user 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,waitRevision:r.waitRevision,progress:r.progress,nextAction:r.nextAction,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,diagnostics:report.diagnostics,execution:report.execution,modelCalls:report.modelCalls}:({...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}: {})};},
78
82
  async wait(id,exec,{after,waitFor='progress',timeoutMs=60000,signal}={}){
79
83
  const r=await owned(id,exec);
80
84
  if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');
@@ -0,0 +1,53 @@
1
+ import { hash } from './content.mjs';
2
+ import { validateBusinessGroups } from './business-groups.mjs';
3
+
4
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])])) : value;
5
+ const digest = value => hash(JSON.stringify(canonical(value)));
6
+ const safeFile = file => !file?.stageFailures?.length && !file?.reviewPasses?.some(pass => pass.status !== 'completed') && !file?.windowCoverage?.pending?.length && file?.status === 'completed' && file.resumeEligible === true && !file.retrievalLimited && (!file.synthesisStatus || file.synthesisStatus === 'completed') && (!file.verificationStatus || !['pending', 'failed'].includes(file.verificationStatus));
7
+
8
+ // Host-private, schema-versioned completed-chain state. A previous result grants
9
+ // neither source authority nor model approval. Exact whole-snapshot binding is
10
+ // deliberately conservative: any changed context/rule/source invalidates reuse.
11
+ export function buildResumeState(snapshot, options, report) {
12
+ if (typeof snapshot?.id !== 'string' || !Array.isArray(snapshot.files) || !report || !Array.isArray(report.files) || !Array.isArray(report.findings)) throw new Error('Invalid resume checkpoint');
13
+ const approved = new Map(snapshot.files.filter(file => file.eligibility === 'reviewable').map(file => [file.id, file]));
14
+ const completed = [];
15
+ for (const state of report.files) {
16
+ const file = approved.get(state.fileId);
17
+ if (!file || file.path !== state.path || !safeFile(state)) continue;
18
+ if (completed.some(item => item.fileId === file.id)) throw new Error('Duplicate resume file');
19
+ const findings = report.findings.filter(finding => finding.fileId === file.id);
20
+ if (findings.some(finding => finding.verification?.status !== 'completed') || (report.interactions ?? []).some(item => item.fileIds?.includes(file.id) && item.status !== 'completed')) continue;
21
+ completed.push({ fileId: file.id, path: file.path, state: structuredClone(state), findings: structuredClone(findings), limitations: structuredClone((report.limitations ?? []).filter(item => item.fileId === file.id)) });
22
+ }
23
+ let businessGrouping;
24
+ if (report.businessGrouping?.groups) {
25
+ validateBusinessGroups({ groups: report.businessGrouping.groups.map(group => ({ fileIds: group.fileIds, reason: group.reason })) }, snapshot.files);
26
+ businessGrouping = structuredClone(report.businessGrouping);
27
+ }
28
+ const interactions = structuredClone((report.interactions ?? []).filter(item => Array.isArray(item.fileIds) && item.fileIds.length && item.status === 'completed' && item.fileIds.every(id => completed.some(row => row.fileId === id))));
29
+ const identity = digest({ snapshot, options });
30
+ const result = { schemaVersion: 1, snapshotId: snapshot.id, identityHash: identity, completed, ...(businessGrouping ? { businessGrouping } : {}), interactions };
31
+ if (Buffer.byteLength(JSON.stringify(result)) > 8 * 1024 * 1024) throw new Error('Resume checkpoint exceeds storage limit');
32
+ return result;
33
+ }
34
+
35
+ export function validateResumeState(value, snapshot, options) {
36
+ if (!value || value.schemaVersion !== 1 || value.snapshotId !== snapshot?.id || value.identityHash !== digest({ snapshot, options }) || !Array.isArray(value.completed) || value.completed.length > snapshot.files.length) throw new Error('Resume identity mismatch; capture a fresh independent review');
37
+ const seen = new Set();
38
+ for (const item of value.completed) {
39
+ const source = snapshot.files.find(file => file.id === item?.fileId && file.path === item.path && file.eligibility === 'reviewable');
40
+ if (!source || seen.has(item.fileId) || !safeFile(item.state) || item.state.fileId !== item.fileId || item.state.path !== item.path || !Array.isArray(item.findings) || item.findings.some(finding => finding?.fileId !== item.fileId || finding.verification?.status !== 'completed') || !Array.isArray(item.limitations) || item.limitations.some(item => item?.fileId !== source.id)) throw new Error('Invalid completed resume chain');
41
+ seen.add(item.fileId);
42
+ }
43
+ if (value.businessGrouping !== undefined) {
44
+ if (!Array.isArray(value.businessGrouping?.groups)) throw new Error('Invalid resume business partition');
45
+ validateBusinessGroups({ groups: value.businessGrouping.groups.map(group => ({ fileIds: group.fileIds, reason: group.reason })) }, snapshot.files);
46
+ }
47
+ if (!Array.isArray(value.interactions) || value.interactions.some(item => !Array.isArray(item?.fileIds) || !item.fileIds.length || item.status !== 'completed' || item.fileIds.some(id => !seen.has(id)))) throw new Error('Invalid resume interactions');
48
+ return structuredClone(value);
49
+ }
50
+
51
+ export function resumePreview(state, sourceJobId) {
52
+ return { sourceJobId, reusableFiles: state.completed.length, fileIds: state.completed.map(item => item.fileId), notice: 'Only exact-identity completed file chains are reusable. Pending/interrupted stages run again; old findings retain prior-run provenance, not fresh model evidence. Fresh preview confirmation and model authorization are still required.' };
53
+ }