@feng0531/dsh-code-review 0.85.13 → 0.86.1

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,40 @@
1
+ import { preparePrimaryInput } from './primary-input.mjs';
2
+ import { initialInputBudget } from './input-budget.mjs';
3
+ import { INLINE_RISK_INSTRUCTIONS } from './prompts.mjs';
4
+ import { validateRiskPlan } from './risk-plan.mjs';
5
+
6
+ export const GROUP_PRIMARY_INSTRUCTIONS = '批准的group-primary输入包含多个完整主文件。逐个审核所有reviews成员的新旧正文,并结合组内其他文件分析;不能仅审核一个文件。返回 {"reviews":[{"fileId":"原成员身份","findings":[],"limitations":[]}],"limitations":[]},每个成员恰好一项,包括没有发现的成员。各finding仍需原审查指令全部字段、精确原行anchor及该fileId本次变化归因;不能用其他成员的身份或坐标替代。缺证据写limitations,不声明跨文件综合已完成;专门交互阶段仍独立校验。可按批准检索协议先返回requests。';
7
+
8
+ export function prepareGroupPrimary(snapshot, group, { instructions, scope, maxInputBytes, focusPrimaryContext = false, enableRiskPlanning = false } = {}) {
9
+ if (!scope || snapshot.projectScope || !Array.isArray(group?.fileIds) || group.fileIds.length < 2 || group.fileIds.length > 4 || new Set(group.fileIds).size !== group.fileIds.length) return null;
10
+ const members = group.fileIds.map(id => snapshot.files.find(file => file.id === id));
11
+ if (members.some(file => !file || file.eligibility !== 'reviewable')) return null;
12
+ const prepared = members.map(file => preparePrimaryInput(snapshot, file, { instructions, scope, maxInputBytes, focusPrimaryContext, riskPlanningMode: 'separate', enableRiskPlanning: false }));
13
+ if (prepared.some(p => p.batches || p.synthesis || p.metadata.sourceMode || p.changes.precision !== 'exact')) return null;
14
+ const reviews = prepared.map((p, i) => ({ ...JSON.parse(p.payload), fileId: members[i].id }));
15
+ const context = [...new Map(reviews.flatMap(r => r.context ?? []).map(item => [JSON.stringify(item), item])).values()];
16
+ for (const item of reviews) { delete item.context; delete item.rules; delete item.businessRequirement; delete item.requirementNotice; }
17
+ const value = { snapshotId: snapshot.id, sourceMode: 'group-primary', groupId: group.id, reviews, context, rules: snapshot.rules ?? [], ...(snapshot.businessRequirement !== undefined ? { businessRequirement: snapshot.businessRequirement } : {}) };
18
+ const payload = JSON.stringify(value), combined = instructions + '\n' + GROUP_PRIMARY_INSTRUCTIONS + (enableRiskPlanning ? '\n各成员可附带risks;组风险可引用所有成员精确编辑,仍只是假设,不增加规划调用。' + INLINE_RISK_INSTRUCTIONS.replace('Final JSON requires findings/limitations;', 'Each reviews member requires findings/limitations;') : '');
19
+ return initialInputBudget({ input: payload, instructions: combined }, scope, maxInputBytes).fits ? { payload, instructions: combined, fileIds: [...group.fileIds], inlineRisk: enableRiskPlanning, changes: new Map(members.map((f, i) => [f.id, prepared[i].changes])) } : null;
20
+ }
21
+
22
+ export function validateGroupPrimary(response, prepared, snapshot, validate, catalog = []) {
23
+ if (Buffer.byteLength(typeof response === 'string' ? response : JSON.stringify(response) ?? '') > 128 * 1024) throw new Error('Grouped primary response too large');
24
+ const value = typeof response === 'string' ? JSON.parse(response) : response;
25
+ if (!value || !Array.isArray(value.reviews) || value.reviews.length !== prepared.fileIds.length || !Array.isArray(value.limitations) || value.limitations.length > 50 || value.limitations.some(s => typeof s !== 'string' || !s.trim() || s.length > 8000)) throw new Error('Invalid grouped primary response');
26
+ const output = new Map();
27
+ for (const row of value.reviews) {
28
+ if (!prepared.fileIds.includes(row?.fileId) || output.has(row.fileId)) throw new Error('Unknown or duplicate grouped primary member');
29
+ const file = snapshot.files.find(f => f.id === row.fileId);
30
+ if (row.findings?.some(finding => finding.interaction !== undefined)) throw new Error('Grouped primary cannot bypass dedicated interaction evidence validation');
31
+ const generated = validate(row, file, prepared.changes.get(row.fileId));
32
+ generated.limitations = [...generated.limitations, ...value.limitations];
33
+ if (prepared.inlineRisk) {
34
+ try { generated.groupRisk = row.risks === undefined ? { status: 'absent', risks: [] } : { status: 'completed', risks: validateRiskPlan({ risks: row.risks }, prepared.fileIds.map(id => ({ fileId: id, edits: prepared.changes.get(id).edits })), catalog) }; }
35
+ catch { generated.groupRisk = { status: 'invalid', risks: [] }; }
36
+ }
37
+ output.set(row.fileId, generated);
38
+ }
39
+ return output;
40
+ }
@@ -1,10 +1,12 @@
1
1
  import { retrievalRequest } from './retrieval-loop.mjs';
2
+ import { modelWireBudget, nativeWireEnabled } from './model-wire-budget.mjs';
2
3
 
3
4
  // Count the actual initial envelope, including instructions and retrieval catalog.
4
5
  // This is not a token/price estimate and does not reserve later retrieval rounds.
5
6
  export function initialInputBudget(request, scope, maxBytes) {
6
7
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Invalid input budget');
7
8
  const actual = scope ? retrievalRequest(request, scope) : request;
9
+ if (nativeWireEnabled(request, scope)) return modelWireBudget({ ...actual, nativeOutputTools: true }, maxBytes);
8
10
  const bytes = Buffer.byteLength(actual.input) + Buffer.byteLength(actual.instructions);
9
11
  return { bytes, maxBytes, fits: bytes <= maxBytes };
10
12
  }
@@ -12,6 +12,7 @@ function stageContract(p, instructions) {
12
12
  if (Array.isArray(p.candidates)) return VERDICT_CONTRACT;
13
13
  if (/返回\{groups:/.test(instructions)) return GROUPS_CONTRACT;
14
14
  if (/仅制定审查计划/.test(instructions)) return instructions.includes(RISK_PLAN_SCHEMA) ? '本阶段严格按上述risks schema返回,禁止requests/findings;身份逐项核对输入。' : RISK_CONTRACT;
15
+ if (p.sourceMode === 'group-primary') return '本阶段返回{reviews:[{fileId,findings:[],limitations:[],risks:可选数组}],limitations:[]};每项fileId仅来自reviews[].file.id,按文件原始坐标审查,不将分组自动视为交互证据。可按批准检索协议单独返回requests。';
15
16
  if (p.sourceMode === 'file-interaction') return INTERACTION_CONTRACT;
16
17
  if (p.file) return FINDINGS_CONTRACT;
17
18
  return '';
@@ -2,7 +2,28 @@ import { jsonSyntaxDiagnostic } from './model-diagnostics.mjs';
2
2
 
3
3
  // Accept one complete JSON value, optionally inside one exact Markdown fence.
4
4
  // This parser never repairs bytes or invokes a model; orchestration owns corrections.
5
- export function modelJson(text) {
5
+ // Accept only an unchanged leading JSON container followed by unmistakable prose.
6
+ function balancedPrefix(text) {
7
+ if (!['{', '['].includes(text[0])) return undefined;
8
+ const stack = []; let quoted = false, escaped = false;
9
+ for (let i = 0; i < text.length; i++) {
10
+ const c = text[i];
11
+ if (quoted) { if (escaped) escaped = false; else if (c === String.fromCharCode(92)) escaped = true; else if (c === '"') quoted = false; continue; }
12
+ if (c === '"') { quoted = true; continue; }
13
+ if (c === '{' || c === '[') stack.push(c);
14
+ else if (c === '}' || c === ']') {
15
+ if (stack.pop() !== (c === '}' ? '{' : '[')) return undefined;
16
+ if (!stack.length) {
17
+ const body = text.slice(0, i + 1), tail = text.slice(i + 1).trim();
18
+ if (!tail || /[{}\[\]\x60]/u.test(tail) || /^(?:true|false|null|-?\d|[,:;"'])/u.test(tail)) return undefined;
19
+ if (!/^[\p{L}]/u.test(tail)) return undefined;
20
+ try { JSON.parse(body); return body; } catch { return undefined; }
21
+ }
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+ export function modelJson(text, { allowTrailingProse = false } = {}) {
6
27
  const bytes = Buffer.byteLength(text);
7
28
  const trimmed = text.trim();
8
29
  const fence = /^```(?:json)?\r?\n([\s\S]*)\r?\n```$/.exec(trimmed);
@@ -10,6 +31,7 @@ export function modelJson(text) {
10
31
  try {
11
32
  JSON.parse(body);
12
33
  } catch (parseError) {
34
+ if (allowTrailingProse && !fence) { const prefix = balancedPrefix(trimmed); if (prefix !== undefined) return prefix; }
13
35
  const envelope = fence ? 'json-fence' : 'plain';
14
36
  const shape = !trimmed ? 'empty' : trimmed.startsWith('```') && !fence ? 'non-exact-fence' : /^[{\[]/.test(body) ? 'json-like-invalid' : 'non-json-leading';
15
37
  const error = new Error('Model response is not complete JSON (bytes=' + bytes + ', envelope=' + envelope + ', shape=' + shape + '); no repair or retry performed by JSON parser');
@@ -0,0 +1,58 @@
1
+ // Installed dsh-llm ToolSchema: {name,description,parameters}; tool arguments are
2
+ // strings, not parsed objects. This declares a data result, never an executable tool.
3
+ export const RESULT_TOOL_NAME = 'submit_review_result';
4
+ export const NATIVE_RESULT_INSTRUCTIONS = ' Prefer submit_review_result to submit the current stage JSON as tool arguments (data only, no execution). If tools are unavailable, return the same JSON as text in this response. Never submit multiple results or mix a result with prose.';
5
+ const strings = { type: 'array', items: { type: 'string' } };
6
+ const object = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
7
+ const array = items => ({ type: 'array', items });
8
+ const text = { type: 'string' };
9
+ const anchor = { oneOf: [object({ kind: { const: 'line' }, side: { enum: ['old', 'new'] }, start: { type: 'integer', minimum: 1 }, end: { type: 'integer', minimum: 1 }, snippet: text }), object({ kind: { const: 'property' }, name: text }), object({ kind: { const: 'file' } })] };
10
+ const finding = { type: 'object', properties: { fileId: text, severity: { enum: ['critical', 'high', 'medium', 'low'] }, title: text,
11
+ evidence: text, trigger: text, impact: text, suggestion: text, anchor,
12
+ attribution: { type: 'object' }, interaction: { type: 'object' } },
13
+ required: ['fileId', 'severity', 'title', 'evidence', 'trigger', 'impact', 'suggestion', 'anchor'] };
14
+ const findings = array(finding); // Existing validators still enforce exact evidence and identities.
15
+ const risk = object({ id: { type: 'string' }, fileIds: strings,
16
+ editRefs: array(object({ fileId: { type: 'string' }, editId: { type: 'string' } })),
17
+ question: { type: 'string' }, trigger: { type: 'string' }, impact: { type: 'string' }, checks: strings, sourceIds: strings });
18
+ const review = object({ findings, limitations: strings, risks: array(risk) }, ['findings', 'limitations']);
19
+ const requests = object({ requests: { type: 'array', minItems: 1, maxItems: 8, items: { oneOf: [
20
+ object({ kind: { const: 'read' }, id: { type: 'string' }, start: { type: 'integer', minimum: 1 }, count: { type: 'integer', minimum: 1, maximum: 200 } }),
21
+ object({ kind: { const: 'search' }, query: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100 }, sourceIds: strings }, ['kind', 'query']),
22
+ object({ kind: { const: 'find' }, query: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, ['kind', 'query']),
23
+ ] } } });
24
+ export function modelResultStage(input, instructions = '') {
25
+ let p; try { p = JSON.parse(input); } catch { return undefined; }
26
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return undefined;
27
+ if (Array.isArray(p.candidates)) return 'verdicts';
28
+ if (/返回\{groups:/.test(instructions)) return 'groups';
29
+ if (/仅制定审查计划/.test(instructions)) return 'risks';
30
+ if (p.sourceMode === 'group-primary') return 'reviews';
31
+ if (p.file || p.sourceMode === 'file-interaction') return 'findings';
32
+ return undefined;
33
+ }
34
+ export function modelResultTools(input, instructions = '') {
35
+ const stage = modelResultStage(input, instructions);
36
+ if (!stage) return [];
37
+ const final = stage === 'findings' ? review : stage === 'risks' ? object({ risks: array(risk) }) :
38
+ stage === 'groups' ? object({ groups: array(object({ fileIds: strings, reason: { type: 'string' } })) }) :
39
+ stage === 'reviews' ? object({ reviews: array(object({ fileId: { type: 'string' }, findings, limitations: strings, risks: array(risk) }, ['fileId', 'findings', 'limitations'])), limitations: strings }) :
40
+ object({ verdicts: array(object({ candidateId: text, verdict: { enum: ['supported', 'refuted', 'uncertain'] }, reason: text, evidence: array({ type: 'object' }), regression: { type: 'object' } }, ['candidateId', 'verdict', 'reason', 'evidence'])) });
41
+ let p; try { p = JSON.parse(input); } catch { return []; }
42
+ const parameters = p.retrievalFinalization || stage === 'risks' || stage === 'groups' ? final : { type: 'object', oneOf: [final, requests] };
43
+ return [{ name: RESULT_TOOL_NAME, description: 'Submit only the current approved review stage result or approved retrieval requests as JSON data. Does not execute actions.', parameters }];
44
+ }
45
+ // Additional wire bytes beyond the ordinary text contract/input. No token estimate.
46
+ export function nativeResultWireBytes(input, instructions = '') {
47
+ const tools = modelResultTools(input, instructions);
48
+ return tools.length ? Buffer.byteLength(JSON.stringify(tools)) + Buffer.byteLength(NATIVE_RESULT_INSTRUCTIONS) : 0;
49
+ }
50
+ export function validateNativeResult(text, input, instructions) {
51
+ const value = JSON.parse(text), stage = modelResultStage(input, instructions);
52
+ const p = JSON.parse(input);
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
54
+ if (Object.hasOwn(value, 'requests')) return !p.retrievalFinalization && !['risks', 'groups'].includes(stage) && Object.keys(value).length === 1 && Array.isArray(value.requests);
55
+ const allowed = stage === 'findings' ? ['findings', 'limitations', 'risks'] : stage === 'reviews' ? ['reviews', 'limitations'] : [stage];
56
+ const required = stage === 'findings' ? ['findings', 'limitations'] : stage === 'reviews' ? ['reviews', 'limitations'] : [stage];
57
+ return !!stage && Object.keys(value).every(k => allowed.includes(k)) && required.every(k => Array.isArray(value[k]));
58
+ }
@@ -0,0 +1,32 @@
1
+ import { modelContract } from './model-contract.mjs';
2
+ import { nativeResultWireBytes } from './model-tools.mjs';
3
+
4
+ // Same UTF-8 fields checked by the adapter immediately before transmission.
5
+ // Does not estimate provider tokens or invent hidden slack for later corrections.
6
+ export function modelWireBudget(request, maxBytes) {
7
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Invalid model wire budget');
8
+ if (!request || typeof request.instructions !== 'string' || typeof request.input !== 'string') throw new Error('Invalid model wire request');
9
+ if (request.nativeOutputTools !== undefined && typeof request.nativeOutputTools !== 'boolean') throw new Error('nativeOutputTools must be boolean');
10
+ const bytes = Buffer.byteLength(request.input) + Buffer.byteLength(request.instructions + '\n' + modelContract(request.input, request.instructions)) +
11
+ (request.nativeOutputTools === true ? nativeResultWireBytes(request.input, request.instructions) : 0);
12
+ return { bytes, maxBytes, fits: bytes <= maxBytes };
13
+ }
14
+
15
+ // Local to one already captured scope. This is measurement policy only, never a
16
+ // larger source authorization, reader budget, or cross-review/global default.
17
+ const scopePolicies = new WeakMap();
18
+ export function configureWireBudget(scope, { nativeOutputTools } = {}) {
19
+ if (!scope || (typeof scope !== 'object' && typeof scope !== 'function')) throw new Error('Invalid model wire scope');
20
+ if (typeof nativeOutputTools !== 'boolean') throw new Error('nativeOutputTools must be boolean');
21
+ const previous = scopePolicies.get(scope);
22
+ if (previous !== undefined && previous !== nativeOutputTools) throw new Error('Model wire policy already configured');
23
+ scopePolicies.set(scope, nativeOutputTools);
24
+ return scope;
25
+ }
26
+ export function nativeWireEnabled(request, scope) {
27
+ if (request?.nativeOutputTools !== undefined) {
28
+ if (typeof request.nativeOutputTools !== 'boolean') throw new Error('nativeOutputTools must be boolean');
29
+ return request.nativeOutputTools;
30
+ }
31
+ return scope ? scopePolicies.get(scope) === true : false;
32
+ }
@@ -1,4 +1,5 @@
1
1
  import { retrievalRequest } from './retrieval-loop.mjs';
2
+ import { modelWireBudget } from './model-wire-budget.mjs';
2
3
 
3
4
  const MAX_READS = 4, MAX_LINES = 40;
4
5
  // Optional navigation must leave room for normal retrieval and exact evidence
@@ -64,13 +65,17 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
64
65
  if (attempted.size >= MAX_READS) break;
65
66
  if (seeds.some(item => item.request.id === source.id && item.result.start <= coordinate && coordinate < item.result.start + item.result.count)) continue;
66
67
  const budget = scope.budget();
67
- if (budget.remainingCalls <= RESERVE_CALLS || budget.remainingOutputBytes <= RESERVE_BYTES) break;
68
+ if (budget.remainingOutputBytes <= RESERVE_BYTES) break;
68
69
  const operation = { kind: 'read', id: source.id, start: Math.max(1, coordinate - 4), count: MAX_LINES };
69
70
  const key = JSON.stringify(operation);
70
71
  if (attempted.has(key)) continue;
71
72
  attempted.add(key);
72
73
  let item;
73
74
  try {
75
+ // A cached raw delivery costs no new materialization call, but still uses
76
+ // output bytes and must pass the exact operation's approved preflight.
77
+ const cached = budget.reusableResults === true && scope.preflight([operation])?.calls === 0 && scope.canServe?.([operation]) === true;
78
+ if (!cached && budget.remainingCalls <= RESERVE_CALLS) continue;
74
79
  item = scope.transaction(() => {
75
80
  checkSignal();
76
81
  scope.preflight([operation]);
@@ -78,7 +83,7 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
78
83
  checkSignal();
79
84
  const next = retrievalRequest(request, scope, [...seeds, { request: operation, result }]);
80
85
  const remaining = scope.budget();
81
- if (result.start + result.count <= coordinate || remaining.remainingCalls < RESERVE_CALLS || remaining.remainingOutputBytes < RESERVE_BYTES || Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes) throw optionalError();
86
+ if (result.start + result.count <= coordinate || (!cached && remaining.remainingCalls < RESERVE_CALLS) || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
82
87
  return { request: operation, result };
83
88
  });
84
89
  } catch (error) {
@@ -9,7 +9,7 @@ export function projectConcurrency(value = DEFAULT_PROJECT_CONCURRENCY) {
9
9
 
10
10
  // Parallelize independent approved snapshots, never dependent stages within a batch.
11
11
  // Admission and the shared call reservation are synchronous; completion order is not report order.
12
- export async function reviewProjectBatches(batches, model, { concurrency = DEFAULT_PROJECT_CONCURRENCY, remainingCalls, signal, onProgress, maxRetrievalRounds = 3 } = {}) {
12
+ export async function reviewProjectBatches(batches, model, { concurrency = DEFAULT_PROJECT_CONCURRENCY, remainingCalls, signal, onProgress, maxRetrievalRounds = 3, resumeStates, onResumeCheckpoint, onModelArtifact, enableGroupPrimary = false, nativeOutputTools = false } = {}) {
13
13
  projectConcurrency(concurrency);
14
14
  retrievalRounds(maxRetrievalRounds);
15
15
  if (!Array.isArray(batches) || typeof remainingCalls !== 'function') throw new Error('Invalid project execution');
@@ -27,7 +27,8 @@ export async function reviewProjectBatches(batches, model, { concurrency = DEFAU
27
27
  if (batchSignal.aborted || remainingCalls() <= 0) { results[index] = unstarted(batch); continue; }
28
28
  results[index] = await reviewSnapshot(batch, model, {
29
29
  signal: batchSignal, maxCalls: remainingCalls(), remainingCalls, enableRetrieval: true, enableVerification: true,
30
- enableAnchorCorrection: true, correctProtocol: true, maxRetrievalRounds,
30
+ enableGroupPrimary, nativeOutputTools, enableAnchorCorrection: true, correctProtocol: true, maxRetrievalRounds,
31
+ resumeState: resumeStates?.get(batch.id), onResumeCheckpoint: onResumeCheckpoint ? report => onResumeCheckpoint(batch, report) : undefined, onModelArtifact,
31
32
  onProgress: onProgress ? event => onProgress({ ...event, snapshotId: batch.id, batchIndex: index }) : undefined,
32
33
  });
33
34
  } catch (error) {
@@ -1,3 +1,5 @@
1
+ import { REVIEW_RUNTIME } from './runtime-identity.mjs';
2
+ import { buildResumeState, validateResumeState } from './review-resume.mjs';
1
3
  import { renderProjectReport } from './project-report.mjs';
2
4
  import { reviewDiagnostics } from './review-diagnostics.mjs';
3
5
  import { realpath } from 'node:fs/promises';
@@ -69,12 +71,15 @@ async function captureProject(cwd, args, signal) {
69
71
  return { ...result, id: hash(JSON.stringify(result)) };
70
72
  }
71
73
 
72
- export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = DEFAULT_PROJECT_CONCURRENCY, maxRetrievalRounds = 3 } = {}) {
74
+ export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = DEFAULT_PROJECT_CONCURRENCY, maxRetrievalRounds = 3, nativeOutputTools = false, enableGroupPrimary = false } = {}) {
73
75
  for (const n of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid project preview limit');
74
76
  if (!Number.isSafeInteger(projectMaxCalls) || projectMaxCalls < 1) throw new Error('Invalid project model call limit');
75
77
 
76
78
  validateProjectConcurrency(projectConcurrency);
77
79
  retrievalRounds(maxRetrievalRounds);
80
+ if (typeof nativeOutputTools !== 'boolean' || typeof enableGroupPrimary !== 'boolean') throw new Error('Invalid project execution options');
81
+ const resumeIdentity = (args, route) => ({ captureOptions: args, route, runtime: REVIEW_RUNTIME, execution: { projectMaxCalls, projectConcurrency, maxRetrievalRounds, nativeOutputTools, enableGroupPrimary, enableVerification: true, enableAnchorCorrection: true, correctProtocol: true } });
82
+ const projectBinding = snapshot => ({ ...snapshot, files: snapshot.batches.flatMap(batch => batch.files) });
78
83
  const previews = new Map();
79
84
  const lifetime = new AbortController();
80
85
  let busy = false;
@@ -87,21 +92,39 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
87
92
  async preview(args, exec) {
88
93
  if (busy) throw new Error('Review service busy');
89
94
  if (!args || typeof args !== 'object') throw new Error('Project preview parameters required');
90
- if (Object.keys(args).some(k => !['reviewMode', 'repositoryPath', 'selectedPaths'].includes(k)) || args.reviewMode !== 'project') throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');
95
+ if (Object.keys(args).some(k => !['reviewMode', 'repositoryPath', 'selectedPaths', 'resumeFromJobId'].includes(k)) || args.reviewMode !== 'project') throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');
91
96
  const current = owner(exec);
92
97
  busy = true;
93
98
  try {
94
- const s = await captureProject(current.cwd, args, signalFor(exec));
99
+ const captureArgs = structuredClone(args);
100
+ const sourceJobId = captureArgs.resumeFromJobId; delete captureArgs.resumeFromJobId;
101
+ if (sourceJobId !== undefined && (typeof sourceJobId !== 'string' || !/^[a-f0-9-]{36}$/.test(sourceJobId) || typeof exec.resolveReviewResume !== 'function')) throw new Error('Invalid or unavailable resume job');
102
+ const saved = sourceJobId === undefined ? undefined : await exec.resolveReviewResume(sourceJobId);
103
+ const s = await captureProject(current.cwd, captureArgs, signalFor(exec));
104
+ const identity = resumeIdentity(captureArgs, current.route);
105
+ let resumeStates;
106
+ if (saved !== undefined) {
107
+ if (saved?.schemaVersion !== 1 || saved.mode !== 'project' || !Array.isArray(saved.batches)) throw new Error('Invalid project resume checkpoint');
108
+ validateResumeState(saved.binding, projectBinding(s), identity);
109
+ if (saved.batches.length > s.batches.length || new Set(saved.batches.map(item => item?.snapshotId)).size !== saved.batches.length) throw new Error('Duplicate or excessive resume batches');
110
+ resumeStates = new Map(saved.batches.map(item => {
111
+ const batch = s.batches.find(batch => batch.id === item.snapshotId);
112
+ if (!batch) throw new Error('Project resume batch identity mismatch');
113
+ return [batch.id, validateResumeState(item, batch, identity)];
114
+ }));
115
+ }
95
116
  for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id);
96
117
  while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
97
118
  const previewId = randomUUID();
98
- previews.set(previewId, { ...current, args: structuredClone(args), snapshotId: s.id, expires: now() + ttlMs });
119
+ previews.set(previewId, { ...current, args: captureArgs, snapshotId: s.id, resumeStates, sourceJobId, expires: now() + ttlMs });
99
120
  return {
121
+ ...(resumeStates ? { resume: { sourceJobId, reusableFiles: [...resumeStates.values()].reduce((sum, state) => sum + state.completed.length, 0) } } : {}),
100
122
  previewId, snapshotId: s.id, reviewMode: 'project', repositoryRoot: s.root, vcs: s.vcs,
101
123
  model: current.route, modelSendingEnabled: allowModelSending,
102
124
  totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
103
125
  inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes, batches: s.batches.length,
104
- execution: { batchConcurrency: projectConcurrency, maxRetrievalRounds },
126
+ privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
127
+ execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
105
128
  files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
106
129
  notice: s.notice + PREVIEW_NOTICE_SUFFIX + ' Each stage allows at most ' + maxRetrievalRounds + ' retrieval rounds; exhaustion requires a final-only response and explicit unfinished coverage.',
107
130
  };
@@ -130,8 +153,20 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
130
153
  files: s.batches.flatMap(b => b.files.map(f => ({ fileId: f.id, path: f.path, status: f.eligibility === 'reviewable' ? 'pending' : f.eligibility }))),
131
154
  });
132
155
 
156
+ const identity = resumeIdentity(p.args, p.route);
157
+ const states = new Map();
158
+ for (const [id, saved] of p.resumeStates ?? []) {
159
+ const batch = s.batches.find(batch => batch.id === id);
160
+ if (!batch) throw new Error('Project resume batch identity mismatch');
161
+ states.set(id, validateResumeState(saved, batch, identity));
162
+ }
163
+ const persist = async (batch, report) => {
164
+ if (!exec.reviewResumeCheckpoint) return;
165
+ states.set(batch.id, buildResumeState(batch, identity, report));
166
+ await exec.reviewResumeCheckpoint({ schemaVersion: 1, mode: 'project', binding: buildResumeState(projectBinding(s), identity, { files: [], findings: [] }), batches: [...states.values()] });
167
+ };
133
168
  let calls = 0;
134
- const adapter = createDshModel(llm, p.route, { correctJson: true });
169
+ const adapter = createDshModel(llm, p.route, { correctJson: true, useNativeTools: nativeOutputTools });
135
170
  const model = request => {
136
171
  if (calls >= projectMaxCalls) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
137
172
  calls++;
@@ -143,10 +178,11 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
143
178
  };
144
179
 
145
180
  const results = await reviewProjectBatches(s.batches, model, {
146
- concurrency: projectConcurrency, maxRetrievalRounds, signal, remainingCalls: () => projectMaxCalls - calls,
147
- onProgress: exec.reviewCheckpoint,
181
+ concurrency: projectConcurrency, maxRetrievalRounds, enableGroupPrimary, nativeOutputTools, signal, remainingCalls: () => projectMaxCalls - calls,
182
+ onProgress: exec.reviewCheckpoint, resumeStates: states, onResumeCheckpoint: persist, onModelArtifact: exec.onModelArtifact,
148
183
  });
149
184
 
185
+ for (let i = 0; i < results.length; i++) await persist(s.batches[i], results[i]);
150
186
  const files = results.flatMap(r => r.files.map(f => ({ ...f, batchSnapshotId: r.snapshotId })));
151
187
  const coverage = {
152
188
  total: s.selectedFiles,
@@ -165,8 +201,9 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
165
201
  : 'partial',
166
202
  repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
167
203
  totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes,
168
- coverage, files, findings, batches: results,
169
- execution: { batchConcurrency: projectConcurrency, maxRetrievalRounds },
204
+ coverage, files, findings, artifacts: results.flatMap(result => result.artifacts ?? []), batches: results, resume: { reusedFiles: files.filter(file => file.reused).length },
205
+ privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
206
+ execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
170
207
  notice: s.notice + REPORT_NOTICE_SUFFIX,
171
208
  };
172
209
 
@@ -1,5 +1,6 @@
1
1
  import { RETRIEVAL_INSTRUCTIONS } from './prompts.mjs';
2
2
  import { searchSeeds } from './search-seeds.mjs';
3
+ import { modelWireBudget } from './model-wire-budget.mjs';
3
4
  import { executeRetrievalBatch, parseRetrievalResponse } from './retrieval-protocol.mjs';
4
5
 
5
6
  export { RETRIEVAL_INSTRUCTIONS };
@@ -53,7 +54,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
53
54
  const retrieved = structuredClone(initialRetrieved);
54
55
  if (retrieved.length) {
55
56
  const seeded = retrievalRequest(request, scope, retrieved);
56
- if (Buffer.byteLength(seeded.input) + Buffer.byteLength(seeded.instructions) > maxInputBytes) throw new Error('Initial retrieval input budget exceeded');
57
+ if (request.nativeOutputTools === true ? !modelWireBudget(seeded, maxInputBytes).fits : Buffer.byteLength(seeded.input) + Buffer.byteLength(seeded.instructions) > maxInputBytes) throw new Error('Initial retrieval input budget exceeded');
57
58
  // Publish only fragments admitted to an actual outgoing prompt below.
58
59
  }
59
60
  const delivered = new Set();
@@ -65,19 +66,20 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
65
66
  };
66
67
  const checkExhausted = async round => {
67
68
  const budget = scope.budget?.();
68
- if (!finalization && budget && (budget.remainingCalls === 0 || budget.remainingOutputBytes === 0)) {
69
+ if (!finalization && budget && ((budget.remainingCalls === 0 && !budget.reusableResults) || budget.remainingOutputBytes === 0)) {
69
70
  const error = new Error(budget.remainingCalls === 0 ? 'Reader call budget exceeded' : 'Reader output budget exceeded');
70
71
  error.code = budget.remainingCalls === 0 ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED';
71
72
  await finalize(error);
72
73
  } else if (!finalization && round >= maxRounds) await finalize(roundBudgetError());
73
74
  };
75
+ const fits = value => request.nativeOutputTools === true ? modelWireBudget(value, maxInputBytes).fits : Buffer.byteLength(value.input) + Buffer.byteLength(value.instructions) <= maxInputBytes;
74
76
  const nextRequest = records => {
75
77
  const next = retrievalRequest(request, scope, records);
76
78
  if (enableSearchSeeds) {
77
79
  const annotated = { input: JSON.stringify({ ...JSON.parse(next.input), retrievalRounds: { max: maxRounds, remaining: finalization ? 0 : Math.max(0, maxRounds - currentRound) } }),
78
80
  instructions: next.instructions + ' Batch independent exact reads/searches together (1..8). retrievalRounds is per-stage request turns, not reader calls or authority. Search-hit host reads may accompany results; supplied fragments are not complete coverage.' };
79
81
  // Optional guidance must not turn an otherwise admitted envelope into a block.
80
- if (Buffer.byteLength(annotated.input) + Buffer.byteLength(annotated.instructions) <= maxInputBytes) Object.assign(next, annotated);
82
+ if (fits({ ...next, ...annotated })) Object.assign(next, annotated);
81
83
  }
82
84
  if (finalization) {
83
85
  // The live ledger is already present in retrievalBudget. Do not duplicate it
@@ -93,8 +95,8 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
93
95
  request.signal?.throwIfAborted();
94
96
  if (round > 0 || !allowExhaustedInitial) await checkExhausted(round);
95
97
  let { input, instructions } = nextRequest(retrieved);
96
- if (correctProtocol && retrieved.length && Buffer.byteLength(input) + Buffer.byteLength(instructions) > maxInputBytes) input = compactFollowup(input);
97
- if (Buffer.byteLength(input) + Buffer.byteLength(instructions) > maxInputBytes) throw new Error('Retrieval input budget exceeded');
98
+ if (correctProtocol && retrieved.length && !fits({ ...request, input, instructions })) input = compactFollowup(input);
99
+ if (!fits({ ...request, input, instructions })) throw new Error('Retrieval input budget exceeded');
98
100
  request.signal?.throwIfAborted();
99
101
  beforeCall();
100
102
  request.signal?.throwIfAborted();
@@ -161,19 +163,19 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
161
163
  let candidate = [...retrieved, item];
162
164
  let next = nextRequest(candidate);
163
165
  if (correctProtocol) {
164
- while (retrieved.length && Buffer.byteLength(compactFollowup(next.input)) + Buffer.byteLength(next.instructions) > maxInputBytes) {
166
+ while (retrieved.length && !fits({ ...next, input: compactFollowup(next.input) })) {
165
167
  retrieved.shift();
166
168
  await request.onProtocolEvent?.({ action: 'prior-retrieval-deferred', notice: 'Prior returned fragment no longer in current prompt; reread approved source for evidence.' });
167
169
  candidate = [...retrieved, item];
168
170
  next = nextRequest(candidate);
169
171
  }
170
172
  }
171
- let fits = Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) <= maxInputBytes;
172
- if (correctProtocol && !fits) fits = Buffer.byteLength(compactFollowup(next.input)) + Buffer.byteLength(next.instructions) <= maxInputBytes;
173
- if (correctProtocol && !fits) {
173
+ let admitted = fits(next);
174
+ if (correctProtocol && !admitted) admitted = fits({ ...next, input: compactFollowup(next.input) });
175
+ if (correctProtocol && !admitted) {
174
176
  const omitted = { request: item.request, result: { omitted: true, reason: 'Result omitted: request fewer lines/matches; no body or receipt provided.' } };
175
177
  const minimal = nextRequest([...retrieved, omitted]);
176
- if (Buffer.byteLength(compactFollowup(minimal.input)) + Buffer.byteLength(minimal.instructions) > maxInputBytes) throw new Error('Retrieval input metadata exceeds model capacity');
178
+ if (!fits({ ...minimal, input: compactFollowup(minimal.input) })) throw new Error('Retrieval input metadata exceeds model capacity');
177
179
  retrieved.push(omitted);
178
180
  await request.onProtocolEvent?.({ action: 'oversized-retrieval-omitted', kind: item.request.kind });
179
181
  } else {
@@ -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 = 4096 } = {}) {
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
+ }