@shipwithai/loopnote 0.0.0-stage → 2.0.0-bootstrap.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/THIRD_PARTY_NOTICES.md +580 -0
- package/dist/bin/loopnote.mjs +5 -0
- package/dist/cli/confirm.js +38 -0
- package/dist/cli/picker.js +342 -0
- package/dist/cli/request.js +120 -0
- package/dist/cli/run.js +970 -0
- package/dist/contracts/api.js +640 -0
- package/dist/contracts/comparison.js +178 -0
- package/dist/contracts/draft.js +606 -0
- package/dist/contracts/request.js +124 -0
- package/dist/domain/anchor.js +122 -0
- package/dist/domain/comparison-key.js +51 -0
- package/dist/domain/errors.js +43 -0
- package/dist/domain/path-bytes.js +82 -0
- package/dist/domain/product-identity.js +66 -0
- package/dist/domain/source.js +4 -0
- package/dist/draft/mutate-draft.js +87 -0
- package/dist/export/render-review-markdown.js +163 -0
- package/dist/export/review-export.js +273 -0
- package/dist/git/availability.js +92 -0
- package/dist/git/candidates.js +413 -0
- package/dist/git/comparison.js +263 -0
- package/dist/git/exact-patch.js +702 -0
- package/dist/git/ignore-status.js +23 -0
- package/dist/git/inventory.js +160 -0
- package/dist/git/numstat.js +111 -0
- package/dist/git/objects.js +95 -0
- package/dist/git/raw-diff.js +84 -0
- package/dist/git/repository.js +144 -0
- package/dist/git/runner.js +145 -0
- package/dist/git/selector-drift.js +169 -0
- package/dist/git/standalone-resolution.js +174 -0
- package/dist/git/worktree-content.js +97 -0
- package/dist/git/worktree-inventory.js +391 -0
- package/dist/native/directory_exchange.node +0 -0
- package/dist/server/app.js +171 -0
- package/dist/server/attached-completion.js +85 -0
- package/dist/server/capabilities.js +1572 -0
- package/dist/server/draft-loader.js +134 -0
- package/dist/server/draft-recovery.js +5 -0
- package/dist/server/draft-store.js +380 -0
- package/dist/server/export-store.js +291 -0
- package/dist/server/gitignore-capability.js +197 -0
- package/dist/server/image-content.js +101 -0
- package/dist/server/lifecycle.js +72 -0
- package/dist/server/native-exchange-capability.js +45 -0
- package/dist/server/patch-snapshot.js +500 -0
- package/dist/server/routes.js +418 -0
- package/dist/server/security.js +123 -0
- package/dist/server/snapshot-fs.js +58 -0
- package/dist/server/support-client.js +76 -0
- package/dist/server/support-store.js +194 -0
- package/dist/server/worktree-snapshot.js +553 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
- package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-D05T4iGw.js +1 -0
- package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-BDAqstpo.js +1 -0
- package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
- package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
- package/dist/web/assets/index-CCeYBqPo.js +1111 -0
- package/dist/web/assets/index-CRj9ojWJ.css +1 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-1Ywytw67.js +1 -0
- package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
- package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-CET4IumD.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-d-reaHeT.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
- package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DprEVA-S.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
- package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +87 -4
|
@@ -0,0 +1,1572 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { extname as posixExtname } from 'node:path/posix';
|
|
5
|
+
import { ExportReviewResultSchema, AppendLoopnoteIgnoreResultSchema, LoopnoteIgnoreStatusSchema, FileContentResponseSchema, FileMetadataResponseSchema, PatchStatusResponseSchema, SessionResponseSchema, } from '../contracts/api.js';
|
|
6
|
+
import { AttachedCompletionCoordinator } from './attached-completion.js';
|
|
7
|
+
import { buildDurableAnchor, verifyDurableAnchor, } from '../domain/anchor.js';
|
|
8
|
+
import { comparisonKey, uncommittedReviewKey, standaloneReviewKey } from '../domain/comparison-key.js';
|
|
9
|
+
import { decodeBase64url } from '../domain/path-bytes.js';
|
|
10
|
+
import { MAX_INLINE_TEXT_BYTES } from '../git/availability.js';
|
|
11
|
+
import { createObjectReader } from '../git/objects.js';
|
|
12
|
+
import { createGitRunner } from '../git/runner.js';
|
|
13
|
+
import { IMAGE_CONTENT_TYPE_BY_EXTENSION, MAX_IMAGE_BYTES, readCommittedImageBytes, readWorktreeImageBytes, resolveRelativeImagePath, } from './image-content.js';
|
|
14
|
+
import { createSelectorDriftObserver, } from '../git/selector-drift.js';
|
|
15
|
+
import { createDraftStore, loadUncommittedDraftCandidateComments } from './draft-store.js';
|
|
16
|
+
import { assertAttachedStorageScope } from './draft-loader.js';
|
|
17
|
+
import { buildReviewExportV1, buildReviewExportV2, buildReviewExportV3, buildReviewExportV4, buildReviewExportV5, buildReviewExportV6, canonicalizeReviewExport, } from '../export/review-export.js';
|
|
18
|
+
import { parseCanonicalReviewExport } from '../export/review-export.js';
|
|
19
|
+
import { renderReviewMarkdown } from '../export/render-review-markdown.js';
|
|
20
|
+
import { getObservedNativeExchangeCapability } from './native-exchange-capability.js';
|
|
21
|
+
import { assertManagedExportsRoot, ensureManagedExportsRoot, publishReviewExport } from './export-store.js';
|
|
22
|
+
import { inspectLoopnoteIgnore } from '../git/ignore-status.js';
|
|
23
|
+
import { appendLoopnoteIgnoreRule } from './gitignore-capability.js';
|
|
24
|
+
import { PatchSnapshot } from './patch-snapshot.js';
|
|
25
|
+
import {} from './worktree-snapshot.js';
|
|
26
|
+
const LANGUAGE_BY_EXTENSION = {
|
|
27
|
+
c: 'c',
|
|
28
|
+
css: 'css',
|
|
29
|
+
go: 'go',
|
|
30
|
+
h: 'cpp',
|
|
31
|
+
htm: 'html',
|
|
32
|
+
html: 'html',
|
|
33
|
+
java: 'java',
|
|
34
|
+
js: 'javascript',
|
|
35
|
+
json: 'json',
|
|
36
|
+
jsx: 'javascript',
|
|
37
|
+
md: 'markdown',
|
|
38
|
+
mjs: 'javascript',
|
|
39
|
+
mts: 'typescript',
|
|
40
|
+
py: 'python',
|
|
41
|
+
rs: 'rust',
|
|
42
|
+
sh: 'shell',
|
|
43
|
+
sql: 'sql',
|
|
44
|
+
ts: 'typescript',
|
|
45
|
+
tsx: 'typescript',
|
|
46
|
+
vue: 'html',
|
|
47
|
+
xml: 'xml',
|
|
48
|
+
yml: 'yaml',
|
|
49
|
+
yaml: 'yaml',
|
|
50
|
+
};
|
|
51
|
+
const LANGUAGE_BY_BASENAME = {
|
|
52
|
+
dockerfile: 'dockerfile',
|
|
53
|
+
makefile: 'makefile',
|
|
54
|
+
};
|
|
55
|
+
const strictTextDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
56
|
+
function imageContentTypeFor(relativePath) {
|
|
57
|
+
const extension = posixExtname(relativePath).slice(1).toLowerCase();
|
|
58
|
+
return extension.length === 0 ? undefined : IMAGE_CONTENT_TYPE_BY_EXTENSION[extension];
|
|
59
|
+
}
|
|
60
|
+
async function resolveImageBytes(markdownPath, relativeSrc, readBytes) {
|
|
61
|
+
if (markdownPath === undefined) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const resolvedPath = resolveRelativeImagePath(markdownPath.display, relativeSrc);
|
|
65
|
+
if (resolvedPath === undefined) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const contentType = imageContentTypeFor(resolvedPath);
|
|
69
|
+
if (contentType === undefined) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const bytes = await readBytes(resolvedPath);
|
|
73
|
+
return bytes === undefined ? undefined : Object.freeze({ bytes, contentType });
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* WR-01: the base-side (pre-change) text content for a markdown file is
|
|
77
|
+
* always sourced from the git-tracked blob, never the live worktree — but
|
|
78
|
+
* an image *embedded* in that markdown is only "unchanged sibling asset"
|
|
79
|
+
* safe to read from disk when it is not itself one of the diff's own
|
|
80
|
+
* changed files. This locates that changed-file entry (if any) so callers
|
|
81
|
+
* can prefer its recorded base blob over a live disk read.
|
|
82
|
+
*/
|
|
83
|
+
function findChangedImageEntry(files, resolvedPath) {
|
|
84
|
+
return files.find((candidate) => candidate.oldPath?.display === resolvedPath || candidate.newPath?.display === resolvedPath);
|
|
85
|
+
}
|
|
86
|
+
async function readObjectBackedImage(objectReader, blobOid) {
|
|
87
|
+
const content = await objectReader.read(blobOid, { maxBytes: MAX_IMAGE_BYTES });
|
|
88
|
+
return content.kind === 'available' ? Buffer.from(content.bytes) : undefined;
|
|
89
|
+
}
|
|
90
|
+
export function createSupportCapability(store, client) {
|
|
91
|
+
const status = async () => ({ status: (await store.state()).status });
|
|
92
|
+
const promote = async (remote) => {
|
|
93
|
+
if (remote === 'verified')
|
|
94
|
+
await store.markVerified(new Date().toISOString());
|
|
95
|
+
return await status();
|
|
96
|
+
};
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
status,
|
|
99
|
+
async start(action) {
|
|
100
|
+
const result = await client.start(action, (await store.state()).installationId);
|
|
101
|
+
return result === undefined ? { kind: 'unavailable' } : { kind: 'ready', flowUrl: result.flowUrl };
|
|
102
|
+
},
|
|
103
|
+
async refresh() {
|
|
104
|
+
return await promote(await client.status((await store.state()).installationId));
|
|
105
|
+
},
|
|
106
|
+
close() {
|
|
107
|
+
client.close();
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function toSessionEndpoint(endpoint) {
|
|
112
|
+
return {
|
|
113
|
+
label: endpoint.label,
|
|
114
|
+
oid: endpoint.oid,
|
|
115
|
+
...(endpoint.source?.kind === 'worktree'
|
|
116
|
+
? {
|
|
117
|
+
worktree: {
|
|
118
|
+
path: endpoint.source.path,
|
|
119
|
+
dirty: endpoint.source.dirty,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function receiptComparisonEndpoint(endpoint) {
|
|
126
|
+
return {
|
|
127
|
+
label: endpoint.label,
|
|
128
|
+
oid: endpoint.oid,
|
|
129
|
+
...(endpoint.source === undefined ? {} : { selectorType: endpoint.source.kind }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function receiptDriftIdentity(endpoint, status) {
|
|
133
|
+
const selectorType = endpoint.source?.kind ?? 'branch';
|
|
134
|
+
const pinned = { label: endpoint.label, selectorType, oid: endpoint.oid };
|
|
135
|
+
if (status.kind === 'unavailable') {
|
|
136
|
+
return {
|
|
137
|
+
role: status.role,
|
|
138
|
+
pinned,
|
|
139
|
+
current: {
|
|
140
|
+
kind: 'unavailable',
|
|
141
|
+
label: status.label,
|
|
142
|
+
selectorType: status.selectorType,
|
|
143
|
+
reason: status.reason,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
role: status.role,
|
|
149
|
+
pinned,
|
|
150
|
+
current: {
|
|
151
|
+
kind: 'available',
|
|
152
|
+
label: status.kind === 'moved' ? status.label : endpoint.label,
|
|
153
|
+
selectorType: status.kind === 'moved' ? status.selectorType : selectorType,
|
|
154
|
+
oid: status.kind === 'moved' ? status.newOid : endpoint.oid,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function receiptDrift(comparison, observation) {
|
|
159
|
+
const drifted = observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged';
|
|
160
|
+
return drifted
|
|
161
|
+
? {
|
|
162
|
+
kind: 'acknowledged',
|
|
163
|
+
identities: [
|
|
164
|
+
receiptDriftIdentity(comparison.base, observation.base),
|
|
165
|
+
receiptDriftIdentity(comparison.head, observation.head),
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
: { kind: 'noneObserved' };
|
|
169
|
+
}
|
|
170
|
+
function languageForPath(path) {
|
|
171
|
+
const filename = path?.split(/[\\/]/u).at(-1)?.toLowerCase() ?? '';
|
|
172
|
+
if (filename.length === 0) {
|
|
173
|
+
return 'plaintext';
|
|
174
|
+
}
|
|
175
|
+
const namedLanguage = LANGUAGE_BY_BASENAME[filename];
|
|
176
|
+
if (namedLanguage !== undefined) {
|
|
177
|
+
return namedLanguage;
|
|
178
|
+
}
|
|
179
|
+
const extension = filename.split('.').at(-1);
|
|
180
|
+
return extension === undefined ? 'plaintext' : LANGUAGE_BY_EXTENSION[extension] ?? 'plaintext';
|
|
181
|
+
}
|
|
182
|
+
async function readSide(reader, path, blobOid, mode) {
|
|
183
|
+
if (mode === '000000' || path === undefined) {
|
|
184
|
+
return { exists: false };
|
|
185
|
+
}
|
|
186
|
+
const content = await reader.read(blobOid, { maxBytes: MAX_INLINE_TEXT_BYTES });
|
|
187
|
+
if (content.kind === 'missing') {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return {
|
|
192
|
+
exists: true,
|
|
193
|
+
path,
|
|
194
|
+
language: languageForPath(path.utf8),
|
|
195
|
+
blobOid,
|
|
196
|
+
text: strictTextDecoder.decode(content.bytes),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function isCompleteExportDirectory(path) {
|
|
204
|
+
try {
|
|
205
|
+
const directory = await lstat(path);
|
|
206
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const names = await readdir(path);
|
|
210
|
+
if (names.length !== 2 || !names.includes('review.json') || !names.includes('review.md')) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const files = await Promise.all(names.map(async (name) => lstat(join(path, name))));
|
|
214
|
+
return files.every((file) => file.isFile() && !file.isSymbolicLink());
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function createCapabilityRegistry(comparison, options = {}) {
|
|
221
|
+
const storageScope = options.attachedCompletion === undefined
|
|
222
|
+
? undefined
|
|
223
|
+
: assertAttachedStorageScope(options.attachedCompletion.storageScope);
|
|
224
|
+
const filesByCapability = new Map();
|
|
225
|
+
const frozenFilesByCapability = new Map();
|
|
226
|
+
const reader = options.objectReader ?? createObjectReader(comparison.repositoryRoot);
|
|
227
|
+
const draftStore = options.draftStore ??
|
|
228
|
+
createDraftStore({
|
|
229
|
+
repositoryRoot: comparison.repositoryRoot,
|
|
230
|
+
comparison: comparison.range === undefined
|
|
231
|
+
? {
|
|
232
|
+
baseCommitOid: comparison.base.oid,
|
|
233
|
+
headCommitOid: comparison.head.oid,
|
|
234
|
+
mergeBaseOid: comparison.mergeBaseOid,
|
|
235
|
+
}
|
|
236
|
+
: {
|
|
237
|
+
baseCommitOid: comparison.base.oid,
|
|
238
|
+
headCommitOid: comparison.head.oid,
|
|
239
|
+
mergeBaseOid: comparison.mergeBaseOid,
|
|
240
|
+
range: comparison.range,
|
|
241
|
+
},
|
|
242
|
+
...(storageScope === undefined ? {} : { storageScope }),
|
|
243
|
+
});
|
|
244
|
+
const selectorDriftObserver = options.selectorDriftObserver ?? createSelectorDriftObserver(comparison);
|
|
245
|
+
const acknowledgements = new Map();
|
|
246
|
+
const session = SessionResponseSchema.parse({
|
|
247
|
+
base: toSessionEndpoint(comparison.base),
|
|
248
|
+
head: toSessionEndpoint(comparison.head),
|
|
249
|
+
mergeBaseOid: comparison.mergeBaseOid,
|
|
250
|
+
...(comparison.range === undefined
|
|
251
|
+
? {}
|
|
252
|
+
: {
|
|
253
|
+
range: {
|
|
254
|
+
kind: comparison.range.kind,
|
|
255
|
+
baseOid: comparison.range.baseOid,
|
|
256
|
+
headOid: comparison.range.headOid,
|
|
257
|
+
pathspecs: comparison.range.pathspecs,
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
|
|
261
|
+
...(options.support === undefined ? {} : { support: { enabled: true } }),
|
|
262
|
+
repoName: basename(comparison.repositoryRoot),
|
|
263
|
+
files: comparison.changedFiles.map((file) => {
|
|
264
|
+
const status = file.status.similarity === null
|
|
265
|
+
? { kind: file.status.kind }
|
|
266
|
+
: { kind: file.status.kind, similarity: file.status.similarity };
|
|
267
|
+
const sessionFile = {
|
|
268
|
+
fileId: file.id,
|
|
269
|
+
status,
|
|
270
|
+
oldMode: file.oldMode,
|
|
271
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
272
|
+
...(file.newPath === undefined ? {} : { newPath: file.newPath }),
|
|
273
|
+
additions: file.additions,
|
|
274
|
+
deletions: file.deletions,
|
|
275
|
+
availability: file.availability,
|
|
276
|
+
};
|
|
277
|
+
const metadata = FileMetadataResponseSchema.parse({
|
|
278
|
+
...sessionFile,
|
|
279
|
+
oldMode: file.oldMode,
|
|
280
|
+
newMode: file.newMode,
|
|
281
|
+
});
|
|
282
|
+
filesByCapability.set(file.id, metadata);
|
|
283
|
+
frozenFilesByCapability.set(file.id, file);
|
|
284
|
+
return sessionFile;
|
|
285
|
+
}),
|
|
286
|
+
});
|
|
287
|
+
const verifyAnchor = async (anchor) => {
|
|
288
|
+
for (const file of frozenFilesByCapability.values()) {
|
|
289
|
+
const path = anchor.side === 'base' ? file.oldPath : file.newPath;
|
|
290
|
+
const blobOid = anchor.side === 'base' ? file.oldBlobOid : file.newBlobOid;
|
|
291
|
+
const mode = anchor.side === 'base' ? file.oldMode : file.newMode;
|
|
292
|
+
if (file.availability.kind !== 'text' || path === undefined || path.bytesBase64url !== anchor.path.bytesBase64url || blobOid !== anchor.blobOid)
|
|
293
|
+
continue;
|
|
294
|
+
const side = await readSide(reader, path, blobOid, mode);
|
|
295
|
+
if (side === undefined || !side.exists)
|
|
296
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
297
|
+
try {
|
|
298
|
+
return verifyDurableAnchor(anchor, buildDurableAnchor({
|
|
299
|
+
path: side.path,
|
|
300
|
+
safeDisplayPath: side.path.display,
|
|
301
|
+
side: anchor.side,
|
|
302
|
+
blobOid: side.blobOid,
|
|
303
|
+
line: anchor.line,
|
|
304
|
+
endLine: anchor.endLine,
|
|
305
|
+
text: side.text,
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
313
|
+
};
|
|
314
|
+
const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
|
|
315
|
+
status: () => options.attachedCompletion.coordinator.status(),
|
|
316
|
+
markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
|
|
317
|
+
finish: async (expectedRevision) => {
|
|
318
|
+
return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
|
|
319
|
+
const settled = await draftStore.settle(expectedRevision, {
|
|
320
|
+
prepare: async (acceptedDraft) => {
|
|
321
|
+
const observation = await selectorDriftObserver.observe();
|
|
322
|
+
if (observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged' || comparison.range === undefined) {
|
|
323
|
+
return { kind: 'scopeInvalid' };
|
|
324
|
+
}
|
|
325
|
+
const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
|
|
326
|
+
const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
|
|
327
|
+
if (affectedCommentIds.length !== 0)
|
|
328
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
329
|
+
try {
|
|
330
|
+
const range = comparison.range;
|
|
331
|
+
const exportedAt = new Date().toISOString();
|
|
332
|
+
const bytes = canonicalizeReviewExport(buildReviewExportV2({
|
|
333
|
+
acceptedDraft,
|
|
334
|
+
commentVerification: verification,
|
|
335
|
+
comparison: {
|
|
336
|
+
selectedBase: { label: range.requestedBase, launchOid: range.baseOid },
|
|
337
|
+
selectedHead: { label: range.requestedHead, launchOid: range.headOid },
|
|
338
|
+
mergeBaseOid: range.baseOid,
|
|
339
|
+
comparisonKey: range.reviewKey,
|
|
340
|
+
},
|
|
341
|
+
drift: {
|
|
342
|
+
observedAt: exportedAt,
|
|
343
|
+
acknowledged: false,
|
|
344
|
+
base: { launchOid: comparison.base.oid, currentOid: comparison.base.oid, status: 'unchanged' },
|
|
345
|
+
head: { launchOid: comparison.head.oid, currentOid: comparison.head.oid, status: 'unchanged' },
|
|
346
|
+
},
|
|
347
|
+
}, range, exportedAt));
|
|
348
|
+
return { kind: 'ready', bytes, revision: acceptedDraft.revision };
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return { kind: 'canonicalizationFailure' };
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
finalize: async ({ draft, prepared }) => {
|
|
355
|
+
if (prepared.kind !== 'ready')
|
|
356
|
+
return prepared;
|
|
357
|
+
const observation = await selectorDriftObserver.observe();
|
|
358
|
+
if (observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged')
|
|
359
|
+
return { kind: 'scopeInvalid' };
|
|
360
|
+
const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
|
|
361
|
+
if (affectedCommentIds.length !== 0)
|
|
362
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
363
|
+
try {
|
|
364
|
+
const document = parseCanonicalReviewExport(prepared.bytes);
|
|
365
|
+
if (document.schemaVersion !== 2 || document.acceptedDraftRevision !== draft.revision)
|
|
366
|
+
return { kind: 'canonicalizationFailure' };
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return { kind: 'canonicalizationFailure' };
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
if (!await options.attachedCompletion.deliver(prepared.bytes)) {
|
|
373
|
+
return { kind: 'deliveryFailed' };
|
|
374
|
+
}
|
|
375
|
+
return { kind: 'completed', revision: prepared.revision };
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return { kind: 'deliveryFailed' };
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
if (settled.kind === 'accepted')
|
|
383
|
+
return settled.value;
|
|
384
|
+
if (settled.kind === 'revisionConflict')
|
|
385
|
+
return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
|
|
386
|
+
if (settled.kind === 'draftChanged')
|
|
387
|
+
return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
|
|
388
|
+
return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
return Object.freeze({
|
|
393
|
+
session: async () => session,
|
|
394
|
+
attachedCompletion,
|
|
395
|
+
onAnchorAdd: options.onAnchorAdd,
|
|
396
|
+
draftStore,
|
|
397
|
+
selectorDriftObserver,
|
|
398
|
+
support: options.support,
|
|
399
|
+
async revealDraftFile() {
|
|
400
|
+
if (options.revealDraftFile === undefined) {
|
|
401
|
+
throw new Error('Draft reveal adapter is unavailable.');
|
|
402
|
+
}
|
|
403
|
+
await options.revealDraftFile(draftStore.canonicalPath);
|
|
404
|
+
},
|
|
405
|
+
async lookup(fileId) {
|
|
406
|
+
options.onCapabilityLookup?.(fileId);
|
|
407
|
+
return filesByCapability.get(fileId);
|
|
408
|
+
},
|
|
409
|
+
async revealExportDirectory() {
|
|
410
|
+
const managedRoot = await ensureManagedExportsRoot(comparison.repositoryRoot, false);
|
|
411
|
+
const exportName = storageScope
|
|
412
|
+
?? comparison.range?.reviewKey
|
|
413
|
+
?? `${comparison.base.oid}..${comparison.head.oid}`;
|
|
414
|
+
const exportDirectory = managedRoot === undefined
|
|
415
|
+
? undefined
|
|
416
|
+
: join(managedRoot.exportsRoot, exportName);
|
|
417
|
+
if (options.revealDraftFile === undefined
|
|
418
|
+
|| managedRoot === undefined
|
|
419
|
+
|| exportDirectory === undefined) {
|
|
420
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
421
|
+
}
|
|
422
|
+
await assertManagedExportsRoot(managedRoot);
|
|
423
|
+
if (!(await isCompleteExportDirectory(exportDirectory))) {
|
|
424
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
425
|
+
}
|
|
426
|
+
await assertManagedExportsRoot(managedRoot);
|
|
427
|
+
await options.revealDraftFile(exportDirectory);
|
|
428
|
+
},
|
|
429
|
+
async inspectLoopnoteIgnore() {
|
|
430
|
+
const status = await inspectLoopnoteIgnore({
|
|
431
|
+
repositoryRoot: comparison.repositoryRoot,
|
|
432
|
+
});
|
|
433
|
+
return LoopnoteIgnoreStatusSchema.parse({
|
|
434
|
+
kind: status.kind === 'not-ignored' ? 'notIgnored' : status.kind,
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
async appendLoopnoteIgnoreRule() {
|
|
438
|
+
return AppendLoopnoteIgnoreResultSchema.parse(await appendLoopnoteIgnoreRule({
|
|
439
|
+
repositoryRoot: comparison.repositoryRoot,
|
|
440
|
+
}));
|
|
441
|
+
},
|
|
442
|
+
async exportReview(input) {
|
|
443
|
+
const initial = await draftStore.loadState();
|
|
444
|
+
if (initial.kind !== 'current') {
|
|
445
|
+
return ExportReviewResultSchema.parse({ kind: 'draftReadOnly' });
|
|
446
|
+
}
|
|
447
|
+
if (initial.draft.revision !== input.expectedRevision) {
|
|
448
|
+
return ExportReviewResultSchema.parse({
|
|
449
|
+
kind: 'revisionConflict',
|
|
450
|
+
expectedRevision: input.expectedRevision,
|
|
451
|
+
actualRevision: initial.draft.revision,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const observation = await selectorDriftObserver.observe();
|
|
455
|
+
const observationFingerprint = createHash('sha256').update(JSON.stringify(observation)).digest('base64url');
|
|
456
|
+
const drifted = observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged';
|
|
457
|
+
if (drifted && acknowledgements.get(input.driftAcknowledgementToken ?? '') !== observationFingerprint) {
|
|
458
|
+
const acknowledgementToken = randomBytes(32).toString('base64url');
|
|
459
|
+
acknowledgements.set(acknowledgementToken, observationFingerprint);
|
|
460
|
+
return ExportReviewResultSchema.parse({
|
|
461
|
+
kind: input.driftAcknowledgementToken === undefined ? 'driftAcknowledgementRequired' : 'driftAcknowledgementStale',
|
|
462
|
+
acknowledgementToken,
|
|
463
|
+
observation,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const acceptedDraft = structuredClone(initial.draft);
|
|
467
|
+
const draftFingerprint = createHash('sha256').update(initial.raw).digest('hex');
|
|
468
|
+
const exportedAt = new Date().toISOString();
|
|
469
|
+
const commentVerification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await this.verifyAnchor(comment.anchor)])));
|
|
470
|
+
const range = comparison.range;
|
|
471
|
+
if (range !== undefined && (range.baseOid !== comparison.base.oid || range.headOid !== comparison.head.oid)) {
|
|
472
|
+
throw new Error('Frozen comparison does not match its range provenance.');
|
|
473
|
+
}
|
|
474
|
+
const exportSnapshot = {
|
|
475
|
+
acceptedDraft,
|
|
476
|
+
commentVerification,
|
|
477
|
+
comparison: range === undefined
|
|
478
|
+
? {
|
|
479
|
+
selectedBase: { label: comparison.base.label, launchOid: comparison.base.oid },
|
|
480
|
+
selectedHead: { label: comparison.head.label, launchOid: comparison.head.oid },
|
|
481
|
+
mergeBaseOid: comparison.mergeBaseOid,
|
|
482
|
+
comparisonKey: comparisonKey(comparison.base.oid, comparison.head.oid),
|
|
483
|
+
}
|
|
484
|
+
: {
|
|
485
|
+
selectedBase: { label: range.requestedBase, launchOid: range.baseOid },
|
|
486
|
+
selectedHead: { label: range.requestedHead, launchOid: range.headOid },
|
|
487
|
+
mergeBaseOid: range.baseOid,
|
|
488
|
+
comparisonKey: range.reviewKey,
|
|
489
|
+
},
|
|
490
|
+
drift: {
|
|
491
|
+
observedAt: exportedAt,
|
|
492
|
+
acknowledged: drifted,
|
|
493
|
+
base: {
|
|
494
|
+
launchOid: comparison.base.oid,
|
|
495
|
+
currentOid: observation.base.kind === 'moved' ? observation.base.newOid : observation.base.kind === 'unchanged' ? comparison.base.oid : null,
|
|
496
|
+
status: observation.base.kind,
|
|
497
|
+
},
|
|
498
|
+
head: {
|
|
499
|
+
launchOid: comparison.head.oid,
|
|
500
|
+
currentOid: observation.head.kind === 'moved' ? observation.head.newOid : observation.head.kind === 'unchanged' ? comparison.head.oid : null,
|
|
501
|
+
status: observation.head.kind,
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
const document = range === undefined
|
|
506
|
+
? buildReviewExportV1(exportSnapshot, exportedAt)
|
|
507
|
+
: buildReviewExportV2(exportSnapshot, range, exportedAt);
|
|
508
|
+
const json = canonicalizeReviewExport(document);
|
|
509
|
+
const markdown = Buffer.from(renderReviewMarkdown(json), 'utf8');
|
|
510
|
+
const published = await publishReviewExport({
|
|
511
|
+
repositoryRoot: comparison.repositoryRoot,
|
|
512
|
+
identity: range === undefined
|
|
513
|
+
? { kind: 'interactive', baseOid: comparison.base.oid, headOid: comparison.head.oid }
|
|
514
|
+
: { kind: 'range', reviewKey: range.reviewKey },
|
|
515
|
+
...(storageScope === undefined ? {} : { storageScope }),
|
|
516
|
+
json,
|
|
517
|
+
markdown,
|
|
518
|
+
reExportCapability: await getObservedNativeExchangeCapability(),
|
|
519
|
+
revalidate: async () => {
|
|
520
|
+
const current = await draftStore.loadState();
|
|
521
|
+
if (current.kind !== 'current' ||
|
|
522
|
+
current.draft.revision !== acceptedDraft.revision ||
|
|
523
|
+
createHash('sha256').update(current.raw).digest('hex') !== draftFingerprint) {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
return createHash('sha256').update(JSON.stringify(await selectorDriftObserver.observe())).digest('base64url') === observationFingerprint;
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
if (published.kind !== 'exported') {
|
|
530
|
+
return ExportReviewResultSchema.parse({ kind: published.kind });
|
|
531
|
+
}
|
|
532
|
+
return ExportReviewResultSchema.parse({
|
|
533
|
+
kind: 'exported',
|
|
534
|
+
draftRevision: acceptedDraft.revision,
|
|
535
|
+
exportedAt,
|
|
536
|
+
drift: receiptDrift(comparison, observation),
|
|
537
|
+
comparison: {
|
|
538
|
+
base: receiptComparisonEndpoint(comparison.base),
|
|
539
|
+
head: receiptComparisonEndpoint(comparison.head),
|
|
540
|
+
},
|
|
541
|
+
files: published.receipt.files,
|
|
542
|
+
});
|
|
543
|
+
},
|
|
544
|
+
async readContent(fileId) {
|
|
545
|
+
const file = frozenFilesByCapability.get(fileId);
|
|
546
|
+
if (file === undefined || file.availability.kind !== 'text') {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
const [base, head] = await Promise.all([
|
|
550
|
+
readSide(reader, file.oldPath, file.oldBlobOid, file.oldMode),
|
|
551
|
+
readSide(reader, file.newPath, file.newBlobOid, file.newMode),
|
|
552
|
+
]);
|
|
553
|
+
if (base === undefined || head === undefined) {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
return FileContentResponseSchema.parse({ fileId, base, head });
|
|
557
|
+
},
|
|
558
|
+
async readRelativeImage(fileId, side, relativeSrc) {
|
|
559
|
+
const file = frozenFilesByCapability.get(fileId);
|
|
560
|
+
const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
|
|
561
|
+
return resolveImageBytes(markdownPath, relativeSrc, (resolvedPath) => readCommittedImageBytes(comparison.repositoryRoot, side === 'base' ? comparison.base.oid : comparison.head.oid, resolvedPath, createGitRunner(), reader));
|
|
562
|
+
},
|
|
563
|
+
verifyAnchor,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
export async function createExactPatchCapabilityRegistry(grounded, snapshot, options = {}) {
|
|
567
|
+
const storageScope = options.attachedCompletion === undefined
|
|
568
|
+
? undefined
|
|
569
|
+
: assertAttachedStorageScope(options.attachedCompletion.storageScope);
|
|
570
|
+
const sessionResponse = async () => SessionResponseSchema.parse({
|
|
571
|
+
...(await snapshot.session()),
|
|
572
|
+
...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
|
|
573
|
+
...(options.support === undefined ? {} : { support: { enabled: true } }),
|
|
574
|
+
repoName: basename(grounded.repositoryRoot),
|
|
575
|
+
});
|
|
576
|
+
const session = await sessionResponse();
|
|
577
|
+
if (!('patch' in session))
|
|
578
|
+
throw new Error('Exact patch snapshot did not provide patch provenance.');
|
|
579
|
+
const patchSession = session.patch;
|
|
580
|
+
const draftStore = options.draftStore ?? createDraftStore({
|
|
581
|
+
repositoryRoot: grounded.repositoryRoot,
|
|
582
|
+
comparison: {
|
|
583
|
+
kind: 'exact-patch',
|
|
584
|
+
digest: patchSession.digest,
|
|
585
|
+
validationTarget: patchSession.validationTarget,
|
|
586
|
+
reviewKey: patchSession.reviewKey,
|
|
587
|
+
},
|
|
588
|
+
...(storageScope === undefined ? {} : { storageScope }),
|
|
589
|
+
});
|
|
590
|
+
const verifyAnchor = async (anchor) => {
|
|
591
|
+
const file = (await snapshot.files()).find((candidate) => {
|
|
592
|
+
const path = anchor.side === 'base' ? candidate.oldPath : candidate.newPath;
|
|
593
|
+
const blobOid = anchor.side === 'base' ? candidate.oldBlobOid : candidate.newBlobOid;
|
|
594
|
+
return path?.bytesBase64url === anchor.path.bytesBase64url && blobOid === anchor.blobOid;
|
|
595
|
+
});
|
|
596
|
+
if (file === undefined)
|
|
597
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
598
|
+
const content = await snapshot.readContent(file.id);
|
|
599
|
+
const bytes = anchor.side === 'base' ? content?.preimage : content?.postimage;
|
|
600
|
+
const path = anchor.side === 'base' ? file.oldPath : file.newPath;
|
|
601
|
+
if (bytes === undefined || path === undefined)
|
|
602
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
603
|
+
return verifyDurableAnchor(anchor, buildDurableAnchor({
|
|
604
|
+
path,
|
|
605
|
+
safeDisplayPath: path.display,
|
|
606
|
+
side: anchor.side,
|
|
607
|
+
blobOid: anchor.blobOid,
|
|
608
|
+
line: anchor.line,
|
|
609
|
+
endLine: anchor.endLine,
|
|
610
|
+
text: strictTextDecoder.decode(bytes),
|
|
611
|
+
}));
|
|
612
|
+
};
|
|
613
|
+
const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
|
|
614
|
+
status: () => options.attachedCompletion.coordinator.status(),
|
|
615
|
+
markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
|
|
616
|
+
finish: async (expectedRevision) => {
|
|
617
|
+
return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
|
|
618
|
+
const settled = await draftStore.settle(expectedRevision, {
|
|
619
|
+
prepare: async (acceptedDraft) => {
|
|
620
|
+
const patch = await snapshot.exportScope();
|
|
621
|
+
if (patch.snapshot.status !== 'unchanged')
|
|
622
|
+
return { kind: 'scopeInvalid' };
|
|
623
|
+
const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
|
|
624
|
+
const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
|
|
625
|
+
if (affectedCommentIds.length !== 0)
|
|
626
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
627
|
+
try {
|
|
628
|
+
const bytes = canonicalizeReviewExport(buildReviewExportV3({ acceptedDraft, commentVerification: verification }, patch, new Date().toISOString()));
|
|
629
|
+
return { kind: 'ready', bytes, revision: acceptedDraft.revision };
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return { kind: 'canonicalizationFailure' };
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
finalize: async ({ draft, prepared }) => {
|
|
636
|
+
if (prepared.kind !== 'ready')
|
|
637
|
+
return prepared;
|
|
638
|
+
if ((await snapshot.exportScope()).snapshot.status !== 'unchanged')
|
|
639
|
+
return { kind: 'scopeInvalid' };
|
|
640
|
+
const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
|
|
641
|
+
if (affectedCommentIds.length !== 0)
|
|
642
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
643
|
+
try {
|
|
644
|
+
const document = parseCanonicalReviewExport(prepared.bytes);
|
|
645
|
+
if (document.schemaVersion !== 3 || document.acceptedDraftRevision !== draft.revision)
|
|
646
|
+
return { kind: 'canonicalizationFailure' };
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return { kind: 'canonicalizationFailure' };
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
if (!await options.attachedCompletion.deliver(prepared.bytes)) {
|
|
653
|
+
return { kind: 'deliveryFailed' };
|
|
654
|
+
}
|
|
655
|
+
return { kind: 'completed', revision: prepared.revision };
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
return { kind: 'deliveryFailed' };
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
if (settled.kind === 'accepted')
|
|
663
|
+
return settled.value;
|
|
664
|
+
if (settled.kind === 'revisionConflict')
|
|
665
|
+
return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
|
|
666
|
+
if (settled.kind === 'draftChanged')
|
|
667
|
+
return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
|
|
668
|
+
return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
|
|
669
|
+
});
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
return Object.freeze({
|
|
673
|
+
isExactPatch: true,
|
|
674
|
+
session: sessionResponse,
|
|
675
|
+
attachedCompletion,
|
|
676
|
+
onAnchorAdd: options.onAnchorAdd,
|
|
677
|
+
draftStore,
|
|
678
|
+
support: options.support,
|
|
679
|
+
patchStatus: async () => {
|
|
680
|
+
const status = await snapshot.observe();
|
|
681
|
+
return PatchStatusResponseSchema.parse(status === 'snapshotUnavailable'
|
|
682
|
+
? { kind: 'snapshotUnavailable' }
|
|
683
|
+
: { kind: status, validationTargetLabel: snapshot.validationTargetLabel });
|
|
684
|
+
},
|
|
685
|
+
async lookup(fileId) {
|
|
686
|
+
options.onCapabilityLookup?.(fileId);
|
|
687
|
+
const file = await snapshot.lookup(fileId);
|
|
688
|
+
if (file === undefined)
|
|
689
|
+
return undefined;
|
|
690
|
+
return FileMetadataResponseSchema.parse({
|
|
691
|
+
fileId: file.id,
|
|
692
|
+
status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
|
|
693
|
+
oldMode: file.oldMode,
|
|
694
|
+
newMode: file.newMode,
|
|
695
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
696
|
+
...(file.newPath === undefined ? {} : { newPath: file.newPath }),
|
|
697
|
+
additions: file.additions,
|
|
698
|
+
deletions: file.deletions,
|
|
699
|
+
availability: file.availability,
|
|
700
|
+
});
|
|
701
|
+
},
|
|
702
|
+
async readContent(fileId) {
|
|
703
|
+
const content = await snapshot.readContent(fileId);
|
|
704
|
+
if (content === undefined)
|
|
705
|
+
return undefined;
|
|
706
|
+
const { file, preimage, postimage } = content;
|
|
707
|
+
const makeSide = (bytes, path, mode, blobOid) => bytes === undefined || path === undefined || mode === '000000'
|
|
708
|
+
? { exists: false }
|
|
709
|
+
: {
|
|
710
|
+
exists: true,
|
|
711
|
+
path,
|
|
712
|
+
language: languageForPath(path.utf8),
|
|
713
|
+
blobOid,
|
|
714
|
+
text: strictTextDecoder.decode(bytes),
|
|
715
|
+
};
|
|
716
|
+
return FileContentResponseSchema.parse({
|
|
717
|
+
fileId,
|
|
718
|
+
base: makeSide(preimage, file.oldPath, file.oldMode, file.oldBlobOid),
|
|
719
|
+
head: makeSide(postimage, file.newPath, file.newMode, file.newBlobOid),
|
|
720
|
+
});
|
|
721
|
+
},
|
|
722
|
+
async readRelativeImage(fileId, side, relativeSrc) {
|
|
723
|
+
const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
|
|
724
|
+
const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
|
|
725
|
+
return resolveImageBytes(markdownPath, relativeSrc, async (resolvedPath) => {
|
|
726
|
+
if (side === 'base') {
|
|
727
|
+
const changedEntry = findChangedImageEntry(await snapshot.files(), resolvedPath);
|
|
728
|
+
if (changedEntry !== undefined) {
|
|
729
|
+
// Image is itself part of the diff: serve the materialized, digest-verified
|
|
730
|
+
// preimage (or nothing, if it did not exist at base) instead of live disk bytes.
|
|
731
|
+
return changedEntry.oldMode === '000000' ? undefined : snapshot.readPreimageBytes(changedEntry.id);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return readWorktreeImageBytes(grounded.repositoryRoot, resolvedPath); // unchanged sibling asset, or head side
|
|
735
|
+
});
|
|
736
|
+
},
|
|
737
|
+
verifyAnchor,
|
|
738
|
+
async revealDraftFile() {
|
|
739
|
+
if (options.revealDraftFile === undefined)
|
|
740
|
+
throw new Error('Draft reveal adapter is unavailable.');
|
|
741
|
+
await options.revealDraftFile(draftStore.canonicalPath);
|
|
742
|
+
},
|
|
743
|
+
async revealExportDirectory() {
|
|
744
|
+
if (options.revealDraftFile === undefined)
|
|
745
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
746
|
+
const managedRoot = await ensureManagedExportsRoot(grounded.repositoryRoot, false);
|
|
747
|
+
if (managedRoot === undefined)
|
|
748
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
749
|
+
const exportDirectory = join(managedRoot.exportsRoot, storageScope ?? patchSession.reviewKey);
|
|
750
|
+
await assertManagedExportsRoot(managedRoot);
|
|
751
|
+
if (!(await isCompleteExportDirectory(exportDirectory)))
|
|
752
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
753
|
+
await assertManagedExportsRoot(managedRoot);
|
|
754
|
+
await options.revealDraftFile(exportDirectory);
|
|
755
|
+
},
|
|
756
|
+
async exportReview(input) {
|
|
757
|
+
const initial = await draftStore.loadState();
|
|
758
|
+
if (initial.kind !== 'current')
|
|
759
|
+
return ExportReviewResultSchema.parse({ kind: 'draftReadOnly' });
|
|
760
|
+
if (initial.draft.revision !== input.expectedRevision) {
|
|
761
|
+
return ExportReviewResultSchema.parse({
|
|
762
|
+
kind: 'revisionConflict',
|
|
763
|
+
expectedRevision: input.expectedRevision,
|
|
764
|
+
actualRevision: initial.draft.revision,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
const acceptedDraft = structuredClone(initial.draft);
|
|
768
|
+
const draftFingerprint = createHash('sha256').update(initial.raw).digest('hex');
|
|
769
|
+
const patch = await snapshot.exportScope();
|
|
770
|
+
const exportedAt = new Date().toISOString();
|
|
771
|
+
const commentVerification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
|
|
772
|
+
const json = canonicalizeReviewExport(buildReviewExportV3({ acceptedDraft, commentVerification }, patch, exportedAt));
|
|
773
|
+
const published = await publishReviewExport({
|
|
774
|
+
repositoryRoot: grounded.repositoryRoot,
|
|
775
|
+
identity: { kind: 'exact-patch', reviewKey: patch.reviewKey },
|
|
776
|
+
...(storageScope === undefined ? {} : { storageScope }),
|
|
777
|
+
json,
|
|
778
|
+
markdown: Buffer.from(renderReviewMarkdown(json), 'utf8'),
|
|
779
|
+
reExportCapability: await getObservedNativeExchangeCapability(),
|
|
780
|
+
revalidate: async () => {
|
|
781
|
+
const current = await draftStore.loadState();
|
|
782
|
+
if (current.kind !== 'current'
|
|
783
|
+
|| current.draft.revision !== acceptedDraft.revision
|
|
784
|
+
|| createHash('sha256').update(current.raw).digest('hex') !== draftFingerprint) {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
return (await snapshot.exportScope()).snapshot.status === patch.snapshot.status;
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
if (published.kind !== 'exported')
|
|
791
|
+
return ExportReviewResultSchema.parse({ kind: published.kind });
|
|
792
|
+
return ExportReviewResultSchema.parse({
|
|
793
|
+
kind: 'exported',
|
|
794
|
+
draftRevision: acceptedDraft.revision,
|
|
795
|
+
exportedAt,
|
|
796
|
+
patch: {
|
|
797
|
+
digest: patch.digest,
|
|
798
|
+
validationTarget: patch.validationTarget,
|
|
799
|
+
reviewKey: patch.reviewKey,
|
|
800
|
+
snapshot: { status: patch.snapshot.status },
|
|
801
|
+
},
|
|
802
|
+
files: published.receipt.files,
|
|
803
|
+
});
|
|
804
|
+
},
|
|
805
|
+
async inspectLoopnoteIgnore() {
|
|
806
|
+
return LoopnoteIgnoreStatusSchema.parse({ kind: 'unavailable' });
|
|
807
|
+
},
|
|
808
|
+
async appendLoopnoteIgnoreRule() {
|
|
809
|
+
return AppendLoopnoteIgnoreResultSchema.parse({ kind: 'unconfirmed' });
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
export async function createUncommittedCapabilityRegistry(scope, snapshot, options = {}) {
|
|
814
|
+
if (scope.sourceKind === 'standalone') {
|
|
815
|
+
throw new RangeError('Unreachable: createUncommittedCapabilityRegistry does not support standalone scopes; standalone sessions use a dedicated capability registry.');
|
|
816
|
+
}
|
|
817
|
+
const sourceKind = scope.sourceKind;
|
|
818
|
+
const storageScope = options.attachedCompletion === undefined
|
|
819
|
+
? undefined
|
|
820
|
+
: assertAttachedStorageScope(options.attachedCompletion.storageScope);
|
|
821
|
+
const snapshotSession = await snapshot.session();
|
|
822
|
+
const reviewKey = uncommittedReviewKey(scope.sourceKind, scope.baseCommitOid, snapshotSession.chain[0]);
|
|
823
|
+
// WR-01: WorktreeSnapshot never captures image bytes (it only captures
|
|
824
|
+
// text-availability sides), so a dedicated ObjectReader is needed to
|
|
825
|
+
// serve the base-side blob of an image that is itself part of the diff.
|
|
826
|
+
const reader = options.objectReader ?? createObjectReader(scope.repositoryRoot);
|
|
827
|
+
const sessionResponse = async () => {
|
|
828
|
+
const files = await snapshot.files();
|
|
829
|
+
return SessionResponseSchema.parse({
|
|
830
|
+
uncommitted: {
|
|
831
|
+
kind: 'uncommitted',
|
|
832
|
+
sourceKind: scope.sourceKind,
|
|
833
|
+
...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
|
|
834
|
+
reviewKey,
|
|
835
|
+
changedFileCount: files.length,
|
|
836
|
+
...(options.uncommittedRecoveryCandidate === undefined ? {} : { recoveryAvailable: true }),
|
|
837
|
+
},
|
|
838
|
+
files: files.map((file) => ({
|
|
839
|
+
fileId: file.id,
|
|
840
|
+
status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
|
|
841
|
+
oldMode: file.oldMode,
|
|
842
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
843
|
+
...(file.newPath === undefined ? {} : { newPath: file.newPath }),
|
|
844
|
+
additions: file.additions,
|
|
845
|
+
deletions: file.deletions,
|
|
846
|
+
availability: file.availability,
|
|
847
|
+
})),
|
|
848
|
+
...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
|
|
849
|
+
...(options.support === undefined ? {} : { support: { enabled: true } }),
|
|
850
|
+
repoName: basename(scope.repositoryRoot),
|
|
851
|
+
});
|
|
852
|
+
};
|
|
853
|
+
const draftStore = options.draftStore ?? createDraftStore({
|
|
854
|
+
repositoryRoot: scope.repositoryRoot,
|
|
855
|
+
comparison: {
|
|
856
|
+
kind: 'uncommitted',
|
|
857
|
+
sourceKind: scope.sourceKind,
|
|
858
|
+
...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
|
|
859
|
+
reviewKey,
|
|
860
|
+
snapshotChain: snapshotSession.chain,
|
|
861
|
+
},
|
|
862
|
+
...(storageScope === undefined ? {} : { storageScope }),
|
|
863
|
+
});
|
|
864
|
+
const verifyAnchor = async (anchor) => {
|
|
865
|
+
const file = (await snapshot.files()).find((candidate) => {
|
|
866
|
+
const path = anchor.side === 'base' ? candidate.oldPath : candidate.newPath;
|
|
867
|
+
return path?.bytesBase64url === anchor.path.bytesBase64url;
|
|
868
|
+
});
|
|
869
|
+
if (file === undefined)
|
|
870
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
871
|
+
const content = await snapshot.readContent(file.id);
|
|
872
|
+
const bytes = anchor.side === 'base' ? content?.preimage : content?.postimage;
|
|
873
|
+
const blobOid = anchor.side === 'base' ? content?.preimageBlobOid : content?.postimageBlobOid;
|
|
874
|
+
const path = anchor.side === 'base' ? file.oldPath : file.newPath;
|
|
875
|
+
if (bytes === undefined || path === undefined || blobOid === undefined || blobOid !== anchor.blobOid) {
|
|
876
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
877
|
+
}
|
|
878
|
+
return verifyDurableAnchor(anchor, buildDurableAnchor({
|
|
879
|
+
path,
|
|
880
|
+
safeDisplayPath: path.display,
|
|
881
|
+
side: anchor.side,
|
|
882
|
+
blobOid: anchor.blobOid,
|
|
883
|
+
line: anchor.line,
|
|
884
|
+
endLine: anchor.endLine,
|
|
885
|
+
text: strictTextDecoder.decode(bytes),
|
|
886
|
+
}));
|
|
887
|
+
};
|
|
888
|
+
const diskPathFor = (file) => {
|
|
889
|
+
const path = file.newPath ?? file.oldPath;
|
|
890
|
+
if (path === undefined)
|
|
891
|
+
return undefined;
|
|
892
|
+
return join(scope.repositoryRoot, Buffer.from(decodeBase64url(path.bytesBase64url)).toString('utf8'));
|
|
893
|
+
};
|
|
894
|
+
const indexStatIdentity = async () => {
|
|
895
|
+
const runner = createGitRunner();
|
|
896
|
+
const result = await runner.run(['rev-parse', '--git-path', 'index'], { cwd: scope.repositoryRoot });
|
|
897
|
+
const indexPathRaw = result.stdout.toString('utf8').trim();
|
|
898
|
+
const resolvedIndexPath = indexPathRaw.startsWith('/') ? indexPathRaw : join(scope.repositoryRoot, indexPathRaw);
|
|
899
|
+
const info = await lstat(resolvedIndexPath);
|
|
900
|
+
return { size: info.size, mtimeMs: info.mtimeMs };
|
|
901
|
+
};
|
|
902
|
+
const captureChangeBaseline = async () => {
|
|
903
|
+
const index = await indexStatIdentity();
|
|
904
|
+
const files = await snapshot.files();
|
|
905
|
+
const fileStats = new Map();
|
|
906
|
+
for (const file of files) {
|
|
907
|
+
const diskPath = diskPathFor(file);
|
|
908
|
+
if (diskPath === undefined)
|
|
909
|
+
continue;
|
|
910
|
+
try {
|
|
911
|
+
const info = await lstat(diskPath);
|
|
912
|
+
fileStats.set(file.id, { size: info.size, mtimeMs: info.mtimeMs });
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
fileStats.set(file.id, undefined);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return Object.freeze({ index, files: fileStats });
|
|
919
|
+
};
|
|
920
|
+
let changeBaseline = await captureChangeBaseline();
|
|
921
|
+
const collectWorkingTreeDrift = async () => {
|
|
922
|
+
const changedPaths = [];
|
|
923
|
+
const files = await snapshot.files();
|
|
924
|
+
for (const file of files) {
|
|
925
|
+
const diskPath = diskPathFor(file);
|
|
926
|
+
if (diskPath === undefined)
|
|
927
|
+
continue;
|
|
928
|
+
const expected = changeBaseline.files.get(file.id);
|
|
929
|
+
let actual;
|
|
930
|
+
try {
|
|
931
|
+
const info = await lstat(diskPath);
|
|
932
|
+
actual = { size: info.size, mtimeMs: info.mtimeMs };
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
actual = undefined;
|
|
936
|
+
}
|
|
937
|
+
const changed = expected === undefined
|
|
938
|
+
? actual !== undefined
|
|
939
|
+
: actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
|
|
940
|
+
if (!changed)
|
|
941
|
+
continue;
|
|
942
|
+
const path = file.newPath ?? file.oldPath;
|
|
943
|
+
if (path !== undefined)
|
|
944
|
+
changedPaths.push(path);
|
|
945
|
+
}
|
|
946
|
+
return { changed: changedPaths.length > 0, paths: changedPaths };
|
|
947
|
+
};
|
|
948
|
+
const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
|
|
949
|
+
status: () => options.attachedCompletion.coordinator.status(),
|
|
950
|
+
markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
|
|
951
|
+
finish: async (expectedRevision) => {
|
|
952
|
+
return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
|
|
953
|
+
const settled = await draftStore.settle(expectedRevision, {
|
|
954
|
+
prepare: async (acceptedDraft) => {
|
|
955
|
+
const drift = await collectWorkingTreeDrift();
|
|
956
|
+
const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
|
|
957
|
+
const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
|
|
958
|
+
if (affectedCommentIds.length !== 0)
|
|
959
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
960
|
+
try {
|
|
961
|
+
const exportedAt = new Date().toISOString();
|
|
962
|
+
const bytes = canonicalizeReviewExport(buildReviewExportV5({ acceptedDraft, commentVerification: verification }, {
|
|
963
|
+
sourceKind,
|
|
964
|
+
baseCommitOid: scope.baseCommitOid,
|
|
965
|
+
pathspecs: options.requestPathspecs ?? [],
|
|
966
|
+
reviewKey,
|
|
967
|
+
snapshotChain: snapshotSession.chain,
|
|
968
|
+
}, drift, exportedAt));
|
|
969
|
+
return { kind: 'ready', bytes, revision: acceptedDraft.revision };
|
|
970
|
+
}
|
|
971
|
+
catch {
|
|
972
|
+
return { kind: 'canonicalizationFailure' };
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
finalize: async ({ draft, prepared }) => {
|
|
976
|
+
if (prepared.kind !== 'ready')
|
|
977
|
+
return prepared;
|
|
978
|
+
const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
|
|
979
|
+
if (affectedCommentIds.length !== 0)
|
|
980
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
981
|
+
try {
|
|
982
|
+
const document = parseCanonicalReviewExport(prepared.bytes);
|
|
983
|
+
if (document.schemaVersion !== 5 || document.acceptedDraftRevision !== draft.revision)
|
|
984
|
+
return { kind: 'canonicalizationFailure' };
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
return { kind: 'canonicalizationFailure' };
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
if (!await options.attachedCompletion.deliver(prepared.bytes)) {
|
|
991
|
+
return { kind: 'deliveryFailed' };
|
|
992
|
+
}
|
|
993
|
+
return { kind: 'completed', revision: prepared.revision };
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
return { kind: 'deliveryFailed' };
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
if (settled.kind === 'accepted')
|
|
1001
|
+
return settled.value;
|
|
1002
|
+
if (settled.kind === 'revisionConflict')
|
|
1003
|
+
return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
|
|
1004
|
+
if (settled.kind === 'draftChanged')
|
|
1005
|
+
return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
|
|
1006
|
+
return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
|
|
1007
|
+
});
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
return Object.freeze({
|
|
1011
|
+
session: sessionResponse,
|
|
1012
|
+
attachedCompletion,
|
|
1013
|
+
onAnchorAdd: options.onAnchorAdd,
|
|
1014
|
+
draftStore,
|
|
1015
|
+
support: options.support,
|
|
1016
|
+
async refresh() {
|
|
1017
|
+
const stateBefore = await draftStore.loadState();
|
|
1018
|
+
const commentsBefore = stateBefore.kind === 'current' ? stateBefore.draft.comments : [];
|
|
1019
|
+
const verificationBefore = new Map();
|
|
1020
|
+
for (const comment of commentsBefore) {
|
|
1021
|
+
verificationBefore.set(comment.id, await verifyAnchor(comment.anchor));
|
|
1022
|
+
}
|
|
1023
|
+
const delta = await snapshot.refresh();
|
|
1024
|
+
changeBaseline = await captureChangeBaseline();
|
|
1025
|
+
let staleCount = 0;
|
|
1026
|
+
let orphanedCount = 0;
|
|
1027
|
+
for (const comment of commentsBefore) {
|
|
1028
|
+
if (verificationBefore.get(comment.id)?.state !== 'verified')
|
|
1029
|
+
continue;
|
|
1030
|
+
const after = await verifyAnchor(comment.anchor);
|
|
1031
|
+
if (after.state === 'stale')
|
|
1032
|
+
staleCount += 1;
|
|
1033
|
+
else if (after.state === 'orphaned')
|
|
1034
|
+
orphanedCount += 1;
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
added: delta.added,
|
|
1038
|
+
removed: delta.removed,
|
|
1039
|
+
modified: delta.modified,
|
|
1040
|
+
staleCount,
|
|
1041
|
+
orphanedCount,
|
|
1042
|
+
};
|
|
1043
|
+
},
|
|
1044
|
+
async worktreeChangeIndicator() {
|
|
1045
|
+
const index = await indexStatIdentity();
|
|
1046
|
+
if (index.size !== changeBaseline.index.size || index.mtimeMs !== changeBaseline.index.mtimeMs) {
|
|
1047
|
+
return { changed: true };
|
|
1048
|
+
}
|
|
1049
|
+
const files = await snapshot.files();
|
|
1050
|
+
for (const file of files) {
|
|
1051
|
+
const diskPath = diskPathFor(file);
|
|
1052
|
+
if (diskPath === undefined)
|
|
1053
|
+
continue;
|
|
1054
|
+
const expected = changeBaseline.files.get(file.id);
|
|
1055
|
+
let actual;
|
|
1056
|
+
try {
|
|
1057
|
+
const info = await lstat(diskPath);
|
|
1058
|
+
actual = { size: info.size, mtimeMs: info.mtimeMs };
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
actual = undefined;
|
|
1062
|
+
}
|
|
1063
|
+
const changed = expected === undefined
|
|
1064
|
+
? actual !== undefined
|
|
1065
|
+
: actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
|
|
1066
|
+
if (changed)
|
|
1067
|
+
return { changed: true };
|
|
1068
|
+
}
|
|
1069
|
+
return { changed: false };
|
|
1070
|
+
},
|
|
1071
|
+
...(options.uncommittedRecoveryCandidate === undefined ? {} : {
|
|
1072
|
+
async recoverUncommittedDraft() {
|
|
1073
|
+
const comments = await loadUncommittedDraftCandidateComments(options.uncommittedRecoveryCandidate.path);
|
|
1074
|
+
if (comments === undefined)
|
|
1075
|
+
return { kind: 'unavailable' };
|
|
1076
|
+
const state = await draftStore.loadState();
|
|
1077
|
+
let revision = state.kind === 'current' ? state.draft.revision : 0;
|
|
1078
|
+
for (const comment of comments) {
|
|
1079
|
+
const added = await draftStore.mutate({
|
|
1080
|
+
expectedRevision: revision,
|
|
1081
|
+
mutation: { type: 'addComment', body: comment.body, anchor: comment.anchor },
|
|
1082
|
+
});
|
|
1083
|
+
if (added.kind === 'invalidTarget')
|
|
1084
|
+
continue;
|
|
1085
|
+
if (added.kind !== 'accepted')
|
|
1086
|
+
return { kind: 'unavailable' };
|
|
1087
|
+
revision = added.draft.revision;
|
|
1088
|
+
if (comment.state !== 'resolved')
|
|
1089
|
+
continue;
|
|
1090
|
+
const newId = added.draft.comments[added.draft.comments.length - 1].id;
|
|
1091
|
+
const resolved = await draftStore.mutate({
|
|
1092
|
+
expectedRevision: revision,
|
|
1093
|
+
mutation: { type: 'resolveComment', commentId: newId },
|
|
1094
|
+
});
|
|
1095
|
+
if (resolved.kind !== 'accepted')
|
|
1096
|
+
return { kind: 'unavailable' };
|
|
1097
|
+
revision = resolved.draft.revision;
|
|
1098
|
+
}
|
|
1099
|
+
return { kind: 'recovered' };
|
|
1100
|
+
},
|
|
1101
|
+
}),
|
|
1102
|
+
async lookup(fileId) {
|
|
1103
|
+
options.onCapabilityLookup?.(fileId);
|
|
1104
|
+
const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
|
|
1105
|
+
if (file === undefined)
|
|
1106
|
+
return undefined;
|
|
1107
|
+
return FileMetadataResponseSchema.parse({
|
|
1108
|
+
fileId: file.id,
|
|
1109
|
+
status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
|
|
1110
|
+
oldMode: file.oldMode,
|
|
1111
|
+
newMode: file.newMode,
|
|
1112
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
1113
|
+
...(file.newPath === undefined ? {} : { newPath: file.newPath }),
|
|
1114
|
+
additions: file.additions,
|
|
1115
|
+
deletions: file.deletions,
|
|
1116
|
+
availability: file.availability,
|
|
1117
|
+
});
|
|
1118
|
+
},
|
|
1119
|
+
async readContent(fileId) {
|
|
1120
|
+
const content = await snapshot.readContent(fileId);
|
|
1121
|
+
if (content === undefined)
|
|
1122
|
+
return undefined;
|
|
1123
|
+
const { file, preimage, preimageBlobOid, postimage, postimageBlobOid } = content;
|
|
1124
|
+
const makeSide = (bytes, path, mode, blobOid) => bytes === undefined || path === undefined || mode === '000000'
|
|
1125
|
+
? { exists: false }
|
|
1126
|
+
: {
|
|
1127
|
+
exists: true,
|
|
1128
|
+
path,
|
|
1129
|
+
language: languageForPath(path.utf8),
|
|
1130
|
+
blobOid,
|
|
1131
|
+
text: strictTextDecoder.decode(bytes),
|
|
1132
|
+
};
|
|
1133
|
+
return FileContentResponseSchema.parse({
|
|
1134
|
+
fileId,
|
|
1135
|
+
base: makeSide(preimage, file.oldPath, file.oldMode, preimageBlobOid),
|
|
1136
|
+
head: makeSide(postimage, file.newPath, file.newMode, postimageBlobOid),
|
|
1137
|
+
});
|
|
1138
|
+
},
|
|
1139
|
+
async readRelativeImage(fileId, side, relativeSrc) {
|
|
1140
|
+
const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
|
|
1141
|
+
const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
|
|
1142
|
+
return resolveImageBytes(markdownPath, relativeSrc, async (resolvedPath) => {
|
|
1143
|
+
if (side === 'base') {
|
|
1144
|
+
const changedEntry = findChangedImageEntry(await snapshot.files(), resolvedPath);
|
|
1145
|
+
if (changedEntry !== undefined) {
|
|
1146
|
+
// Image is itself part of the diff: serve its base blob via the
|
|
1147
|
+
// object store instead of the live disk (which is the head side).
|
|
1148
|
+
return changedEntry.oldMode === '000000' ? undefined : readObjectBackedImage(reader, changedEntry.oldBlobOid);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return readWorktreeImageBytes(scope.repositoryRoot, resolvedPath); // unchanged sibling asset, or head side
|
|
1152
|
+
});
|
|
1153
|
+
},
|
|
1154
|
+
verifyAnchor,
|
|
1155
|
+
async revealDraftFile() {
|
|
1156
|
+
if (options.revealDraftFile === undefined)
|
|
1157
|
+
throw new Error('Draft reveal adapter is unavailable.');
|
|
1158
|
+
await options.revealDraftFile(draftStore.canonicalPath);
|
|
1159
|
+
},
|
|
1160
|
+
async inspectLoopnoteIgnore() {
|
|
1161
|
+
return LoopnoteIgnoreStatusSchema.parse({ kind: 'unavailable' });
|
|
1162
|
+
},
|
|
1163
|
+
async appendLoopnoteIgnoreRule() {
|
|
1164
|
+
return AppendLoopnoteIgnoreResultSchema.parse({ kind: 'unconfirmed' });
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
export async function createStandaloneCapabilityRegistry(scope, snapshot, options = {}) {
|
|
1169
|
+
if (scope.sourceKind !== 'standalone') {
|
|
1170
|
+
throw new RangeError('Unreachable: createStandaloneCapabilityRegistry only supports standalone scopes; non-standalone scopes use createUncommittedCapabilityRegistry.');
|
|
1171
|
+
}
|
|
1172
|
+
const storageScope = options.attachedCompletion === undefined
|
|
1173
|
+
? undefined
|
|
1174
|
+
: assertAttachedStorageScope(options.attachedCompletion.storageScope);
|
|
1175
|
+
const snapshotSession = await snapshot.session();
|
|
1176
|
+
const reviewKey = standaloneReviewKey([...scope.standaloneArguments].sort(), snapshotSession.chain[0]);
|
|
1177
|
+
// WR-01: WorktreeSnapshot never captures image bytes (it only captures
|
|
1178
|
+
// text-availability sides), so a dedicated ObjectReader is needed to
|
|
1179
|
+
// serve the base-side blob of an image that is itself part of the diff.
|
|
1180
|
+
const reader = options.objectReader ?? createObjectReader(scope.repositoryRoot);
|
|
1181
|
+
const sessionResponse = async () => {
|
|
1182
|
+
const files = await snapshot.files();
|
|
1183
|
+
return SessionResponseSchema.parse({
|
|
1184
|
+
uncommitted: {
|
|
1185
|
+
kind: 'uncommitted',
|
|
1186
|
+
sourceKind: scope.sourceKind,
|
|
1187
|
+
...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
|
|
1188
|
+
reviewKey,
|
|
1189
|
+
changedFileCount: files.length,
|
|
1190
|
+
},
|
|
1191
|
+
files: files.map((file) => ({
|
|
1192
|
+
fileId: file.id,
|
|
1193
|
+
status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
|
|
1194
|
+
oldMode: file.oldMode,
|
|
1195
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
1196
|
+
...(file.newPath === undefined ? {} : { newPath: file.newPath }),
|
|
1197
|
+
additions: file.additions,
|
|
1198
|
+
deletions: file.deletions,
|
|
1199
|
+
availability: file.availability,
|
|
1200
|
+
...(file.standaloneGitState === undefined ? {} : { standaloneGitState: file.standaloneGitState }),
|
|
1201
|
+
})),
|
|
1202
|
+
...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
|
|
1203
|
+
...(options.support === undefined ? {} : { support: { enabled: true } }),
|
|
1204
|
+
repoName: basename(scope.repositoryRoot),
|
|
1205
|
+
});
|
|
1206
|
+
};
|
|
1207
|
+
const draftStore = options.draftStore ?? createDraftStore({
|
|
1208
|
+
repositoryRoot: scope.repositoryRoot,
|
|
1209
|
+
comparison: {
|
|
1210
|
+
kind: 'uncommitted',
|
|
1211
|
+
sourceKind: scope.sourceKind,
|
|
1212
|
+
...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
|
|
1213
|
+
reviewKey,
|
|
1214
|
+
snapshotChain: snapshotSession.chain,
|
|
1215
|
+
},
|
|
1216
|
+
...(storageScope === undefined ? {} : { storageScope }),
|
|
1217
|
+
});
|
|
1218
|
+
const verifyAnchor = async (anchor) => {
|
|
1219
|
+
const file = (await snapshot.files()).find((candidate) => {
|
|
1220
|
+
const path = anchor.side === 'base' ? candidate.oldPath : candidate.newPath;
|
|
1221
|
+
return path?.bytesBase64url === anchor.path.bytesBase64url;
|
|
1222
|
+
});
|
|
1223
|
+
if (file === undefined)
|
|
1224
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
1225
|
+
const content = await snapshot.readContent(file.id);
|
|
1226
|
+
const bytes = anchor.side === 'base' ? content?.preimage : content?.postimage;
|
|
1227
|
+
const blobOid = anchor.side === 'base' ? content?.preimageBlobOid : content?.postimageBlobOid;
|
|
1228
|
+
const path = anchor.side === 'base' ? file.oldPath : file.newPath;
|
|
1229
|
+
if (bytes === undefined || path === undefined || blobOid === undefined || blobOid !== anchor.blobOid) {
|
|
1230
|
+
return { state: 'orphaned', reason: 'anchor-unavailable' };
|
|
1231
|
+
}
|
|
1232
|
+
return verifyDurableAnchor(anchor, buildDurableAnchor({
|
|
1233
|
+
path,
|
|
1234
|
+
safeDisplayPath: path.display,
|
|
1235
|
+
side: anchor.side,
|
|
1236
|
+
blobOid: anchor.blobOid,
|
|
1237
|
+
line: anchor.line,
|
|
1238
|
+
endLine: anchor.endLine,
|
|
1239
|
+
text: strictTextDecoder.decode(bytes),
|
|
1240
|
+
}));
|
|
1241
|
+
};
|
|
1242
|
+
const diskPathFor = (file) => {
|
|
1243
|
+
const path = file.newPath ?? file.oldPath;
|
|
1244
|
+
if (path === undefined)
|
|
1245
|
+
return undefined;
|
|
1246
|
+
return join(scope.repositoryRoot, Buffer.from(decodeBase64url(path.bytesBase64url)).toString('utf8'));
|
|
1247
|
+
};
|
|
1248
|
+
const indexStatIdentity = async () => {
|
|
1249
|
+
const runner = createGitRunner();
|
|
1250
|
+
const result = await runner.run(['rev-parse', '--git-path', 'index'], { cwd: scope.repositoryRoot });
|
|
1251
|
+
const indexPathRaw = result.stdout.toString('utf8').trim();
|
|
1252
|
+
const resolvedIndexPath = indexPathRaw.startsWith('/') ? indexPathRaw : join(scope.repositoryRoot, indexPathRaw);
|
|
1253
|
+
const info = await lstat(resolvedIndexPath);
|
|
1254
|
+
return { size: info.size, mtimeMs: info.mtimeMs };
|
|
1255
|
+
};
|
|
1256
|
+
const captureChangeBaseline = async () => {
|
|
1257
|
+
const index = await indexStatIdentity();
|
|
1258
|
+
const files = await snapshot.files();
|
|
1259
|
+
const fileStats = new Map();
|
|
1260
|
+
for (const file of files) {
|
|
1261
|
+
const diskPath = diskPathFor(file);
|
|
1262
|
+
if (diskPath === undefined)
|
|
1263
|
+
continue;
|
|
1264
|
+
try {
|
|
1265
|
+
const info = await lstat(diskPath);
|
|
1266
|
+
fileStats.set(file.id, { size: info.size, mtimeMs: info.mtimeMs });
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
fileStats.set(file.id, undefined);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return Object.freeze({ index, files: fileStats });
|
|
1273
|
+
};
|
|
1274
|
+
let changeBaseline = await captureChangeBaseline();
|
|
1275
|
+
const collectWorkingTreeDrift = async () => {
|
|
1276
|
+
const changedPaths = [];
|
|
1277
|
+
const files = await snapshot.files();
|
|
1278
|
+
for (const file of files) {
|
|
1279
|
+
const diskPath = diskPathFor(file);
|
|
1280
|
+
if (diskPath === undefined)
|
|
1281
|
+
continue;
|
|
1282
|
+
const expected = changeBaseline.files.get(file.id);
|
|
1283
|
+
let actual;
|
|
1284
|
+
try {
|
|
1285
|
+
const info = await lstat(diskPath);
|
|
1286
|
+
actual = { size: info.size, mtimeMs: info.mtimeMs };
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
actual = undefined;
|
|
1290
|
+
}
|
|
1291
|
+
const changed = expected === undefined
|
|
1292
|
+
? actual !== undefined
|
|
1293
|
+
: actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
|
|
1294
|
+
if (!changed)
|
|
1295
|
+
continue;
|
|
1296
|
+
const path = file.newPath ?? file.oldPath;
|
|
1297
|
+
if (path !== undefined)
|
|
1298
|
+
changedPaths.push(path);
|
|
1299
|
+
}
|
|
1300
|
+
return { changed: changedPaths.length > 0, paths: changedPaths };
|
|
1301
|
+
};
|
|
1302
|
+
const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
|
|
1303
|
+
status: () => options.attachedCompletion.coordinator.status(),
|
|
1304
|
+
markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
|
|
1305
|
+
finish: async (expectedRevision) => {
|
|
1306
|
+
return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
|
|
1307
|
+
const settled = await draftStore.settle(expectedRevision, {
|
|
1308
|
+
prepare: async (acceptedDraft) => {
|
|
1309
|
+
const drift = await collectWorkingTreeDrift();
|
|
1310
|
+
const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
|
|
1311
|
+
const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
|
|
1312
|
+
if (affectedCommentIds.length !== 0)
|
|
1313
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
1314
|
+
const exportedAt = new Date().toISOString();
|
|
1315
|
+
const files = await snapshot.files();
|
|
1316
|
+
const resolvedPaths = files.map((file) => file.newPath);
|
|
1317
|
+
const fileDigests = (await Promise.all(files.map(async (file) => {
|
|
1318
|
+
const content = await snapshot.readContent(file.id);
|
|
1319
|
+
if (content?.postimage === undefined)
|
|
1320
|
+
return undefined;
|
|
1321
|
+
return { path: file.newPath, sha256: createHash('sha256').update(content.postimage).digest('hex') };
|
|
1322
|
+
}))).filter((entry) => entry !== undefined);
|
|
1323
|
+
const standaloneScope = {
|
|
1324
|
+
originalArguments: [...scope.standaloneArguments].sort(),
|
|
1325
|
+
resolvedPaths,
|
|
1326
|
+
fileDigests,
|
|
1327
|
+
snapshotChain: snapshotSession.chain,
|
|
1328
|
+
reviewKey,
|
|
1329
|
+
};
|
|
1330
|
+
try {
|
|
1331
|
+
const bytes = canonicalizeReviewExport(buildReviewExportV6({ acceptedDraft, commentVerification: verification }, standaloneScope, drift, exportedAt));
|
|
1332
|
+
return { kind: 'ready', bytes, revision: acceptedDraft.revision };
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
return { kind: 'canonicalizationFailure' };
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
finalize: async ({ draft, prepared }) => {
|
|
1339
|
+
if (prepared.kind !== 'ready')
|
|
1340
|
+
return prepared;
|
|
1341
|
+
const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
|
|
1342
|
+
if (affectedCommentIds.length !== 0)
|
|
1343
|
+
return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
|
|
1344
|
+
try {
|
|
1345
|
+
const document = parseCanonicalReviewExport(prepared.bytes);
|
|
1346
|
+
if (document.schemaVersion !== 6 || document.acceptedDraftRevision !== draft.revision)
|
|
1347
|
+
return { kind: 'canonicalizationFailure' };
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
return { kind: 'canonicalizationFailure' };
|
|
1351
|
+
}
|
|
1352
|
+
try {
|
|
1353
|
+
if (!await options.attachedCompletion.deliver(prepared.bytes)) {
|
|
1354
|
+
return { kind: 'deliveryFailed' };
|
|
1355
|
+
}
|
|
1356
|
+
return { kind: 'completed', revision: prepared.revision };
|
|
1357
|
+
}
|
|
1358
|
+
catch {
|
|
1359
|
+
return { kind: 'deliveryFailed' };
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
});
|
|
1363
|
+
if (settled.kind === 'accepted')
|
|
1364
|
+
return settled.value;
|
|
1365
|
+
if (settled.kind === 'revisionConflict')
|
|
1366
|
+
return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
|
|
1367
|
+
if (settled.kind === 'draftChanged')
|
|
1368
|
+
return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
|
|
1369
|
+
return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
|
|
1370
|
+
});
|
|
1371
|
+
},
|
|
1372
|
+
};
|
|
1373
|
+
return Object.freeze({
|
|
1374
|
+
session: sessionResponse,
|
|
1375
|
+
attachedCompletion,
|
|
1376
|
+
onAnchorAdd: options.onAnchorAdd,
|
|
1377
|
+
draftStore,
|
|
1378
|
+
support: options.support,
|
|
1379
|
+
async refresh() {
|
|
1380
|
+
const stateBefore = await draftStore.loadState();
|
|
1381
|
+
const commentsBefore = stateBefore.kind === 'current' ? stateBefore.draft.comments : [];
|
|
1382
|
+
const verificationBefore = new Map();
|
|
1383
|
+
for (const comment of commentsBefore) {
|
|
1384
|
+
verificationBefore.set(comment.id, await verifyAnchor(comment.anchor));
|
|
1385
|
+
}
|
|
1386
|
+
const delta = await snapshot.refresh();
|
|
1387
|
+
changeBaseline = await captureChangeBaseline();
|
|
1388
|
+
let staleCount = 0;
|
|
1389
|
+
let orphanedCount = 0;
|
|
1390
|
+
for (const comment of commentsBefore) {
|
|
1391
|
+
if (verificationBefore.get(comment.id)?.state !== 'verified')
|
|
1392
|
+
continue;
|
|
1393
|
+
const after = await verifyAnchor(comment.anchor);
|
|
1394
|
+
if (after.state === 'stale')
|
|
1395
|
+
staleCount += 1;
|
|
1396
|
+
else if (after.state === 'orphaned')
|
|
1397
|
+
orphanedCount += 1;
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
added: delta.added,
|
|
1401
|
+
removed: delta.removed,
|
|
1402
|
+
modified: delta.modified,
|
|
1403
|
+
staleCount,
|
|
1404
|
+
orphanedCount,
|
|
1405
|
+
};
|
|
1406
|
+
},
|
|
1407
|
+
async worktreeChangeIndicator() {
|
|
1408
|
+
const index = await indexStatIdentity();
|
|
1409
|
+
if (index.size !== changeBaseline.index.size || index.mtimeMs !== changeBaseline.index.mtimeMs) {
|
|
1410
|
+
return { changed: true };
|
|
1411
|
+
}
|
|
1412
|
+
const files = await snapshot.files();
|
|
1413
|
+
for (const file of files) {
|
|
1414
|
+
const diskPath = diskPathFor(file);
|
|
1415
|
+
if (diskPath === undefined)
|
|
1416
|
+
continue;
|
|
1417
|
+
const expected = changeBaseline.files.get(file.id);
|
|
1418
|
+
let actual;
|
|
1419
|
+
try {
|
|
1420
|
+
const info = await lstat(diskPath);
|
|
1421
|
+
actual = { size: info.size, mtimeMs: info.mtimeMs };
|
|
1422
|
+
}
|
|
1423
|
+
catch {
|
|
1424
|
+
actual = undefined;
|
|
1425
|
+
}
|
|
1426
|
+
const changed = expected === undefined
|
|
1427
|
+
? actual !== undefined
|
|
1428
|
+
: actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
|
|
1429
|
+
if (changed)
|
|
1430
|
+
return { changed: true };
|
|
1431
|
+
}
|
|
1432
|
+
return { changed: false };
|
|
1433
|
+
},
|
|
1434
|
+
async lookup(fileId) {
|
|
1435
|
+
options.onCapabilityLookup?.(fileId);
|
|
1436
|
+
const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
|
|
1437
|
+
if (file === undefined)
|
|
1438
|
+
return undefined;
|
|
1439
|
+
return FileMetadataResponseSchema.parse({
|
|
1440
|
+
fileId: file.id,
|
|
1441
|
+
status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
|
|
1442
|
+
oldMode: file.oldMode,
|
|
1443
|
+
newMode: file.newMode,
|
|
1444
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
1445
|
+
...(file.newPath === undefined ? {} : { newPath: file.newPath }),
|
|
1446
|
+
additions: file.additions,
|
|
1447
|
+
deletions: file.deletions,
|
|
1448
|
+
availability: file.availability,
|
|
1449
|
+
});
|
|
1450
|
+
},
|
|
1451
|
+
async readContent(fileId) {
|
|
1452
|
+
const content = await snapshot.readContent(fileId);
|
|
1453
|
+
if (content === undefined)
|
|
1454
|
+
return undefined;
|
|
1455
|
+
const { file, preimage, preimageBlobOid, postimage, postimageBlobOid } = content;
|
|
1456
|
+
const makeSide = (bytes, path, mode, blobOid) => bytes === undefined || path === undefined || mode === '000000'
|
|
1457
|
+
? { exists: false }
|
|
1458
|
+
: {
|
|
1459
|
+
exists: true,
|
|
1460
|
+
path,
|
|
1461
|
+
language: languageForPath(path.utf8),
|
|
1462
|
+
blobOid,
|
|
1463
|
+
text: strictTextDecoder.decode(bytes),
|
|
1464
|
+
};
|
|
1465
|
+
return FileContentResponseSchema.parse({
|
|
1466
|
+
fileId,
|
|
1467
|
+
base: makeSide(preimage, file.oldPath, file.oldMode, preimageBlobOid),
|
|
1468
|
+
head: makeSide(postimage, file.newPath, file.newMode, postimageBlobOid),
|
|
1469
|
+
});
|
|
1470
|
+
},
|
|
1471
|
+
async readRelativeImage(fileId, side, relativeSrc) {
|
|
1472
|
+
const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
|
|
1473
|
+
const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
|
|
1474
|
+
return resolveImageBytes(markdownPath, relativeSrc, async (resolvedPath) => {
|
|
1475
|
+
if (side === 'base') {
|
|
1476
|
+
const changedEntry = findChangedImageEntry(await snapshot.files(), resolvedPath);
|
|
1477
|
+
if (changedEntry !== undefined) {
|
|
1478
|
+
// Image is itself part of the diff: serve its base blob via the
|
|
1479
|
+
// object store instead of the live disk (which is the head side).
|
|
1480
|
+
return changedEntry.oldMode === '000000' ? undefined : readObjectBackedImage(reader, changedEntry.oldBlobOid);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return readWorktreeImageBytes(scope.repositoryRoot, resolvedPath); // unchanged sibling asset, or head side
|
|
1484
|
+
});
|
|
1485
|
+
},
|
|
1486
|
+
verifyAnchor,
|
|
1487
|
+
async revealDraftFile() {
|
|
1488
|
+
if (options.revealDraftFile === undefined)
|
|
1489
|
+
throw new Error('Draft reveal adapter is unavailable.');
|
|
1490
|
+
await options.revealDraftFile(draftStore.canonicalPath);
|
|
1491
|
+
},
|
|
1492
|
+
async revealExportDirectory() {
|
|
1493
|
+
if (options.revealDraftFile === undefined)
|
|
1494
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
1495
|
+
const managedRoot = await ensureManagedExportsRoot(scope.repositoryRoot, false);
|
|
1496
|
+
if (managedRoot === undefined)
|
|
1497
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
1498
|
+
const exportDirectory = join(managedRoot.exportsRoot, storageScope ?? reviewKey);
|
|
1499
|
+
await assertManagedExportsRoot(managedRoot);
|
|
1500
|
+
if (!(await isCompleteExportDirectory(exportDirectory)))
|
|
1501
|
+
throw new Error('Export reveal adapter is unavailable.');
|
|
1502
|
+
await assertManagedExportsRoot(managedRoot);
|
|
1503
|
+
await options.revealDraftFile(exportDirectory);
|
|
1504
|
+
},
|
|
1505
|
+
async exportReview(input) {
|
|
1506
|
+
const initial = await draftStore.loadState();
|
|
1507
|
+
if (initial.kind !== 'current')
|
|
1508
|
+
return ExportReviewResultSchema.parse({ kind: 'draftReadOnly' });
|
|
1509
|
+
if (initial.draft.revision !== input.expectedRevision) {
|
|
1510
|
+
return ExportReviewResultSchema.parse({
|
|
1511
|
+
kind: 'revisionConflict',
|
|
1512
|
+
expectedRevision: input.expectedRevision,
|
|
1513
|
+
actualRevision: initial.draft.revision,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
const acceptedDraft = structuredClone(initial.draft);
|
|
1517
|
+
const draftFingerprint = createHash('sha256').update(initial.raw).digest('hex');
|
|
1518
|
+
const exportedAt = new Date().toISOString();
|
|
1519
|
+
const commentVerification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
|
|
1520
|
+
const files = await snapshot.files();
|
|
1521
|
+
const resolvedPaths = files.map((file) => file.newPath);
|
|
1522
|
+
const fileDigests = (await Promise.all(files.map(async (file) => {
|
|
1523
|
+
const content = await snapshot.readContent(file.id);
|
|
1524
|
+
if (content?.postimage === undefined)
|
|
1525
|
+
return undefined;
|
|
1526
|
+
return { path: file.newPath, sha256: createHash('sha256').update(content.postimage).digest('hex') };
|
|
1527
|
+
}))).filter((entry) => entry !== undefined);
|
|
1528
|
+
const standaloneScope = {
|
|
1529
|
+
originalArguments: [...scope.standaloneArguments].sort(),
|
|
1530
|
+
resolvedPaths,
|
|
1531
|
+
fileDigests,
|
|
1532
|
+
snapshotChain: snapshotSession.chain,
|
|
1533
|
+
reviewKey,
|
|
1534
|
+
};
|
|
1535
|
+
const json = canonicalizeReviewExport(buildReviewExportV4({ acceptedDraft, commentVerification }, standaloneScope, exportedAt));
|
|
1536
|
+
const published = await publishReviewExport({
|
|
1537
|
+
repositoryRoot: scope.repositoryRoot,
|
|
1538
|
+
identity: { kind: 'standalone', reviewKey },
|
|
1539
|
+
...(storageScope === undefined ? {} : { storageScope }),
|
|
1540
|
+
json,
|
|
1541
|
+
markdown: Buffer.from(renderReviewMarkdown(json), 'utf8'),
|
|
1542
|
+
reExportCapability: await getObservedNativeExchangeCapability(),
|
|
1543
|
+
revalidate: async () => {
|
|
1544
|
+
const current = await draftStore.loadState();
|
|
1545
|
+
if (current.kind !== 'current'
|
|
1546
|
+
|| current.draft.revision !== acceptedDraft.revision
|
|
1547
|
+
|| createHash('sha256').update(current.raw).digest('hex') !== draftFingerprint) {
|
|
1548
|
+
return false;
|
|
1549
|
+
}
|
|
1550
|
+
const currentSession = await snapshot.session();
|
|
1551
|
+
return currentSession.chain.length === snapshotSession.chain.length
|
|
1552
|
+
&& currentSession.chain.every((digest, index) => digest === snapshotSession.chain[index]);
|
|
1553
|
+
},
|
|
1554
|
+
});
|
|
1555
|
+
if (published.kind !== 'exported')
|
|
1556
|
+
return ExportReviewResultSchema.parse({ kind: published.kind });
|
|
1557
|
+
return ExportReviewResultSchema.parse({
|
|
1558
|
+
kind: 'exported',
|
|
1559
|
+
draftRevision: acceptedDraft.revision,
|
|
1560
|
+
exportedAt,
|
|
1561
|
+
standalone: { reviewKey },
|
|
1562
|
+
files: published.receipt.files,
|
|
1563
|
+
});
|
|
1564
|
+
},
|
|
1565
|
+
async inspectLoopnoteIgnore() {
|
|
1566
|
+
return LoopnoteIgnoreStatusSchema.parse({ kind: 'unavailable' });
|
|
1567
|
+
},
|
|
1568
|
+
async appendLoopnoteIgnoreRule() {
|
|
1569
|
+
return AppendLoopnoteIgnoreResultSchema.parse({ kind: 'unconfirmed' });
|
|
1570
|
+
},
|
|
1571
|
+
});
|
|
1572
|
+
}
|