@shipwithai/loopnote 0.0.0-stage → 2.0.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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -2
  3. package/THIRD_PARTY_NOTICES.md +580 -0
  4. package/dist/bin/loopnote.mjs +5 -0
  5. package/dist/cli/confirm.js +38 -0
  6. package/dist/cli/picker.js +342 -0
  7. package/dist/cli/request.js +120 -0
  8. package/dist/cli/run.js +970 -0
  9. package/dist/contracts/api.js +640 -0
  10. package/dist/contracts/comparison.js +178 -0
  11. package/dist/contracts/draft.js +606 -0
  12. package/dist/contracts/request.js +124 -0
  13. package/dist/domain/anchor.js +122 -0
  14. package/dist/domain/comparison-key.js +51 -0
  15. package/dist/domain/errors.js +43 -0
  16. package/dist/domain/path-bytes.js +82 -0
  17. package/dist/domain/product-identity.js +66 -0
  18. package/dist/domain/source.js +4 -0
  19. package/dist/draft/mutate-draft.js +87 -0
  20. package/dist/export/render-review-markdown.js +163 -0
  21. package/dist/export/review-export.js +273 -0
  22. package/dist/git/availability.js +92 -0
  23. package/dist/git/candidates.js +413 -0
  24. package/dist/git/comparison.js +263 -0
  25. package/dist/git/exact-patch.js +702 -0
  26. package/dist/git/ignore-status.js +23 -0
  27. package/dist/git/inventory.js +160 -0
  28. package/dist/git/numstat.js +111 -0
  29. package/dist/git/objects.js +95 -0
  30. package/dist/git/raw-diff.js +84 -0
  31. package/dist/git/repository.js +144 -0
  32. package/dist/git/runner.js +145 -0
  33. package/dist/git/selector-drift.js +169 -0
  34. package/dist/git/standalone-resolution.js +174 -0
  35. package/dist/git/worktree-content.js +97 -0
  36. package/dist/git/worktree-inventory.js +391 -0
  37. package/dist/native/directory_exchange.node +0 -0
  38. package/dist/server/app.js +171 -0
  39. package/dist/server/attached-completion.js +85 -0
  40. package/dist/server/capabilities.js +1572 -0
  41. package/dist/server/draft-loader.js +134 -0
  42. package/dist/server/draft-recovery.js +5 -0
  43. package/dist/server/draft-store.js +380 -0
  44. package/dist/server/export-store.js +291 -0
  45. package/dist/server/gitignore-capability.js +197 -0
  46. package/dist/server/image-content.js +101 -0
  47. package/dist/server/lifecycle.js +72 -0
  48. package/dist/server/native-exchange-capability.js +45 -0
  49. package/dist/server/patch-snapshot.js +500 -0
  50. package/dist/server/routes.js +418 -0
  51. package/dist/server/security.js +123 -0
  52. package/dist/server/snapshot-fs.js +58 -0
  53. package/dist/server/support-client.js +76 -0
  54. package/dist/server/support-store.js +194 -0
  55. package/dist/server/worktree-snapshot.js +553 -0
  56. package/dist/web/assets/abap-08VXUWAP.js +1 -0
  57. package/dist/web/assets/apex-BWPQTe0t.js +1 -0
  58. package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
  59. package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
  60. package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
  61. package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
  62. package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
  63. package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
  64. package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
  65. package/dist/web/assets/cpp-C7h46wYY.js +1 -0
  66. package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
  67. package/dist/web/assets/csp-bTuwJoIa.js +1 -0
  68. package/dist/web/assets/css-DIMkf-bt.js +3 -0
  69. package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
  70. package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
  71. package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
  72. package/dist/web/assets/dart-onAF5SnQ.js +1 -0
  73. package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
  74. package/dist/web/assets/ecl-D05T4iGw.js +1 -0
  75. package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
  76. package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
  77. package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
  78. package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
  79. package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
  80. package/dist/web/assets/go-C-y9NEjX.js +1 -0
  81. package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
  82. package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
  83. package/dist/web/assets/hcl-CpzslTdj.js +1 -0
  84. package/dist/web/assets/html-BDAqstpo.js +1 -0
  85. package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
  86. package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
  87. package/dist/web/assets/index-CCeYBqPo.js +1111 -0
  88. package/dist/web/assets/index-CRj9ojWJ.css +1 -0
  89. package/dist/web/assets/ini-sBoK_t0W.js +1 -0
  90. package/dist/web/assets/java-BEtHBSE6.js +1 -0
  91. package/dist/web/assets/javascript-1Ywytw67.js +1 -0
  92. package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
  93. package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
  94. package/dist/web/assets/julia-Bri6UV-V.js +1 -0
  95. package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
  96. package/dist/web/assets/less-B9JPFI3C.js +2 -0
  97. package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
  98. package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
  99. package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
  100. package/dist/web/assets/lua-CsQS60Ue.js +1 -0
  101. package/dist/web/assets/m3-D-oSqn_W.js +1 -0
  102. package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
  103. package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
  104. package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
  105. package/dist/web/assets/msdax-DauUninz.js +1 -0
  106. package/dist/web/assets/mysql-SOo6toE5.js +1 -0
  107. package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
  108. package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
  109. package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
  110. package/dist/web/assets/perl-oz_6vUea.js +1 -0
  111. package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
  112. package/dist/web/assets/php-nr791fC2.js +1 -0
  113. package/dist/web/assets/pla-CopQ2nXW.js +1 -0
  114. package/dist/web/assets/postiats-43DmfD33.js +1 -0
  115. package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
  116. package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
  117. package/dist/web/assets/protobuf-C531GsRP.js +2 -0
  118. package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
  119. package/dist/web/assets/python-CET4IumD.js +1 -0
  120. package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
  121. package/dist/web/assets/r-BwWrilGY.js +1 -0
  122. package/dist/web/assets/razor-d-reaHeT.js +1 -0
  123. package/dist/web/assets/redis-ClamHrr6.js +1 -0
  124. package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
  125. package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
  126. package/dist/web/assets/ruby-DezsRK8O.js +1 -0
  127. package/dist/web/assets/rust-DdL9SqIa.js +1 -0
  128. package/dist/web/assets/sb-CcwsVR0C.js +1 -0
  129. package/dist/web/assets/scala-DHpiXF5c.js +1 -0
  130. package/dist/web/assets/scheme-BeGwcela.js +1 -0
  131. package/dist/web/assets/scss-gp-XZpBa.js +3 -0
  132. package/dist/web/assets/shell-CC2rA5mh.js +1 -0
  133. package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
  134. package/dist/web/assets/sophia-CRfGWb83.js +1 -0
  135. package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
  136. package/dist/web/assets/sql-NEE52Syq.js +1 -0
  137. package/dist/web/assets/st-DbInun42.js +1 -0
  138. package/dist/web/assets/swift-Bxkupp3x.js +1 -0
  139. package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
  140. package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
  141. package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
  142. package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
  143. package/dist/web/assets/twig-De2hgUGE.js +1 -0
  144. package/dist/web/assets/typescript-DprEVA-S.js +1 -0
  145. package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
  146. package/dist/web/assets/vb-DV3o63ZY.js +1 -0
  147. package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
  148. package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
  149. package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
  150. package/dist/web/index.html +14 -0
  151. package/package.json +87 -4
@@ -0,0 +1,45 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { RENAME_MAP } from '../domain/product-identity.js';
6
+ const require = createRequire(import.meta.url);
7
+ const unsupportedCapability = Object.freeze({ kind: 'reExportUnsupported' });
8
+ export function createNativeExchangeCapabilityObserver(dependencies) {
9
+ return async () => {
10
+ let probeRoot;
11
+ let capability = unsupportedCapability;
12
+ try {
13
+ probeRoot = await dependencies.mkdtemp(join(tmpdir(), `${RENAME_MAP.slug}-native-exchange-probe-`));
14
+ const addon = dependencies.loadAddon();
15
+ if (addon.probeDirectoryExchange(probeRoot).kind === 'supported') {
16
+ capability = Object.freeze({
17
+ kind: 'observedNativeExchange',
18
+ exchangeDirectories: addon.exchangeDirectories,
19
+ });
20
+ }
21
+ }
22
+ catch {
23
+ capability = unsupportedCapability;
24
+ }
25
+ if (probeRoot !== undefined) {
26
+ try {
27
+ await dependencies.rm(probeRoot, { recursive: true, force: true });
28
+ }
29
+ catch {
30
+ return unsupportedCapability;
31
+ }
32
+ }
33
+ return capability;
34
+ };
35
+ }
36
+ const observeNativeExchangeCapability = createNativeExchangeCapabilityObserver({
37
+ mkdtemp,
38
+ rm,
39
+ loadAddon: () => require('../native/directory_exchange.node'),
40
+ });
41
+ let observedCapability;
42
+ export function getObservedNativeExchangeCapability() {
43
+ observedCapability ??= observeNativeExchangeCapability();
44
+ return observedCapability;
45
+ }
@@ -0,0 +1,500 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, readlink, rename, rm, stat, } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { ChangedFileSchema, ExactPatchValidationTargetSchema, } from '../contracts/comparison.js';
7
+ import { createGitRunner } from '../git/runner.js';
8
+ import { createObjectReader } from '../git/objects.js';
9
+ import { boundedFileBytes, digest, frame, isMissing, MAX_SIDE_BYTES, syncDirectory, writeOwned } from './snapshot-fs.js';
10
+ import { RENAME_MAP } from '../domain/product-identity.js';
11
+ const SNAPSHOT_VERSION = 1;
12
+ const MAX_SNAPSHOT_FILES = 10_000;
13
+ const MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024;
14
+ const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;
15
+ const MAX_TARGET_TREE_BYTES = 64 * 1024 * 1024;
16
+ const PATCH_KEY_DOMAIN = Buffer.from(`${RENAME_MAP.slug}-exact-patch-review-key-v1`, 'utf8');
17
+ const fileIdPattern = /^file_[A-Za-z0-9_-]{43}$/u;
18
+ const strictText = new TextDecoder('utf-8', { fatal: true });
19
+ const SnapshotSideSchema = z.discriminatedUnion('exists', [
20
+ z.strictObject({
21
+ exists: z.literal(false),
22
+ mode: z.string().regex(/^[0-7]{6}$/u),
23
+ blobOid: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u),
24
+ byteLength: z.literal(0),
25
+ }).readonly(),
26
+ z.strictObject({
27
+ exists: z.literal(true),
28
+ path: z.strictObject({
29
+ bytesBase64url: z.string().regex(/^[A-Za-z0-9_-]+$/u),
30
+ display: z.string(),
31
+ utf8: z.string().optional(),
32
+ }).readonly(),
33
+ mode: z.string().regex(/^[0-7]{6}$/u),
34
+ blobOid: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u),
35
+ language: z.string().min(1),
36
+ byteLength: z.number().int().nonnegative().max(MAX_SIDE_BYTES),
37
+ sha256: z.string().regex(/^[0-9a-f]{64}$/u),
38
+ }).readonly(),
39
+ ]).readonly();
40
+ const SnapshotEntrySchema = z.strictObject({
41
+ file: ChangedFileSchema,
42
+ preimage: SnapshotSideSchema,
43
+ postimage: SnapshotSideSchema,
44
+ }).superRefine((entry, context) => {
45
+ for (const [name, side, path, mode, blobOid] of [
46
+ ['preimage', entry.preimage, entry.file.oldPath, entry.file.oldMode, entry.file.oldBlobOid],
47
+ ['postimage', entry.postimage, entry.file.newPath, entry.file.newMode, entry.file.newBlobOid],
48
+ ]) {
49
+ if (side.exists !== (mode !== '000000')
50
+ || side.mode !== mode
51
+ || side.blobOid !== blobOid
52
+ || (side.exists && (path === undefined
53
+ || side.path.bytesBase64url !== path.bytesBase64url
54
+ || side.path.display !== path.display
55
+ || side.path.utf8 !== path.utf8))) {
56
+ context.addIssue({ code: 'custom', message: `${name} does not match its frozen file metadata.` });
57
+ }
58
+ }
59
+ }).readonly();
60
+ const SnapshotManifestSchema = z.strictObject({
61
+ version: z.literal(SNAPSHOT_VERSION),
62
+ digest: z.string().regex(/^[0-9a-f]{64}$/u),
63
+ validationTarget: ExactPatchValidationTargetSchema,
64
+ reviewKey: z.string().regex(/^[0-9a-f]{64}$/u),
65
+ submittedByteLength: z.number().int().positive().max(1_048_576),
66
+ repositoryIdentity: z.string().min(1),
67
+ objectFormat: z.enum(['sha1', 'sha256']),
68
+ entries: z.array(SnapshotEntrySchema).max(MAX_SNAPSHOT_FILES).readonly(),
69
+ provenanceDigest: z.string().regex(/^[0-9a-f]{64}$/u),
70
+ }).superRefine((manifest, context) => {
71
+ const ids = new Set();
72
+ let total = 0;
73
+ for (const entry of manifest.entries) {
74
+ if (ids.has(entry.file.id)) {
75
+ context.addIssue({ code: 'custom', message: 'Snapshot file identifiers must be unique.' });
76
+ }
77
+ ids.add(entry.file.id);
78
+ total += entry.preimage.byteLength + entry.postimage.byteLength;
79
+ }
80
+ if (total > MAX_SNAPSHOT_BYTES) {
81
+ context.addIssue({ code: 'custom', message: 'Snapshot content exceeds its accepted byte ceiling.' });
82
+ }
83
+ }).readonly();
84
+ export class PatchSnapshotError extends Error {
85
+ code;
86
+ retryable;
87
+ constructor(code, retryable) {
88
+ super(code === 'retryable-read' ? 'Frozen patch file is temporarily unavailable.' : 'Frozen patch snapshot is unavailable.');
89
+ this.code = code;
90
+ this.retryable = retryable;
91
+ }
92
+ }
93
+ function reviewKey(grounded) {
94
+ const hash = createHash('sha256');
95
+ hash.update(PATCH_KEY_DOMAIN);
96
+ hash.update(frame(grounded.scope.digest));
97
+ hash.update(frame(grounded.scope.validationTarget.kind));
98
+ hash.update(frame(grounded.repositoryRoot));
99
+ return hash.digest('hex');
100
+ }
101
+ function languageForPath(path) {
102
+ const extension = path?.utf8?.split('.').at(-1);
103
+ return extension === undefined ? 'plaintext' : extension === 'ts' ? 'typescript' : extension === 'js' ? 'javascript' : extension === 'json' ? 'json' : extension === 'md' ? 'markdown' : 'plaintext';
104
+ }
105
+ function side(bytes, path, mode, blobOid) {
106
+ return bytes === undefined
107
+ ? SnapshotSideSchema.parse({ exists: false, mode, blobOid, byteLength: 0 })
108
+ : SnapshotSideSchema.parse({
109
+ exists: true,
110
+ path,
111
+ mode,
112
+ blobOid,
113
+ language: languageForPath(path),
114
+ byteLength: bytes.byteLength,
115
+ sha256: digest(bytes),
116
+ });
117
+ }
118
+ function manifestDigest(manifest) {
119
+ return digest(Buffer.from(JSON.stringify(manifest), 'utf8'));
120
+ }
121
+ function contentName(fileId, sideName) {
122
+ return `${fileId}.${sideName}`;
123
+ }
124
+ function targetLabel(target) {
125
+ return target.kind === 'repository' ? 'Repository content' : 'Worktree';
126
+ }
127
+ function targetPath(root, encoded) {
128
+ const bytes = Buffer.from(encoded, 'base64url');
129
+ if (bytes.length === 0
130
+ || bytes.toString('base64url') !== encoded
131
+ || bytes[0] === 0x2f
132
+ || bytes.at(-1) === 0x2f
133
+ || bytes.includes(0)) {
134
+ return undefined;
135
+ }
136
+ const parts = bytes.toString('latin1').split('/');
137
+ if (parts.some((part) => part === '' || part === '.' || part === '..'))
138
+ return undefined;
139
+ return Buffer.concat([Buffer.from(root, 'utf8'), ...parts.flatMap((part) => [Buffer.from('/'), Buffer.from(part, 'latin1')])]);
140
+ }
141
+ async function worktreeEntry(root, encodedPath, expectedBytes) {
142
+ const completePath = targetPath(root, encodedPath);
143
+ if (completePath === undefined)
144
+ throw new Error('Frozen target path is invalid.');
145
+ const rootBytes = Buffer.from(root, 'utf8');
146
+ const relative = completePath.subarray(rootBytes.byteLength + 1).toString('latin1').split('/');
147
+ let current = rootBytes;
148
+ try {
149
+ for (const [index, part] of relative.entries()) {
150
+ current = Buffer.concat([current, Buffer.from('/'), Buffer.from(part, 'latin1')]);
151
+ const info = await lstat(current);
152
+ if (index < relative.length - 1) {
153
+ if (info.isSymbolicLink() || !info.isDirectory())
154
+ throw new Error('Target path traversal changed.');
155
+ continue;
156
+ }
157
+ if (info.isSymbolicLink()) {
158
+ return Object.freeze({ mode: '120000', bytes: await readlink(current, { encoding: 'buffer' }) });
159
+ }
160
+ if (!info.isFile())
161
+ throw new Error('Target path is not a supported file.');
162
+ return Object.freeze({
163
+ mode: (info.mode & 0o111) === 0 ? '100644' : '100755',
164
+ bytes: await boundedFileBytes(current, expectedBytes),
165
+ });
166
+ }
167
+ }
168
+ catch (error) {
169
+ if (isMissing(error))
170
+ return undefined;
171
+ throw error;
172
+ }
173
+ return undefined;
174
+ }
175
+ async function repositoryTree(root) {
176
+ const runner = createGitRunner();
177
+ const result = await runner.run(['ls-tree', '-r', '-z', '--full-tree', 'HEAD'], {
178
+ cwd: root,
179
+ maxStdoutBytes: MAX_TARGET_TREE_BYTES,
180
+ });
181
+ const tree = new Map();
182
+ for (const record of result.stdout.toString('latin1').split('\0').filter(Boolean)) {
183
+ const tab = record.indexOf('\t');
184
+ const fields = record.slice(0, tab).split(' ');
185
+ if (tab < 0
186
+ || fields.length !== 3
187
+ || !/^[0-7]{6}$/u.test(fields[0])
188
+ || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(fields[2])) {
189
+ throw new Error('Git returned malformed target tree metadata.');
190
+ }
191
+ tree.set(Buffer.from(record.slice(tab + 1), 'latin1').toString('base64url'), {
192
+ mode: fields[0],
193
+ type: fields[1],
194
+ oid: fields[2],
195
+ });
196
+ }
197
+ return tree;
198
+ }
199
+ export class PatchSnapshot {
200
+ manifest;
201
+ observeTarget;
202
+ digest;
203
+ validationTarget;
204
+ reviewKey;
205
+ root;
206
+ validationTargetLabel;
207
+ unavailable = false;
208
+ drifted = false;
209
+ disposed = false;
210
+ constructor(manifest, root, observeTarget) {
211
+ this.manifest = manifest;
212
+ this.observeTarget = observeTarget;
213
+ this.digest = manifest.digest;
214
+ this.validationTarget = manifest.validationTarget;
215
+ this.reviewKey = manifest.reviewKey;
216
+ this.root = root;
217
+ this.validationTargetLabel = targetLabel(manifest.validationTarget);
218
+ }
219
+ fail() {
220
+ this.unavailable = true;
221
+ throw new PatchSnapshotError('snapshot-unavailable', false);
222
+ }
223
+ async verifyRoot() {
224
+ try {
225
+ const info = await stat(this.root);
226
+ if (!info.isDirectory() || info.isSymbolicLink() || this.disposed || (typeof process.getuid === 'function' && info.uid !== process.getuid()) || (info.mode & 0o022) !== 0) {
227
+ this.fail();
228
+ }
229
+ }
230
+ catch {
231
+ this.fail();
232
+ }
233
+ }
234
+ async verifiedManifest() {
235
+ if (this.unavailable)
236
+ this.fail();
237
+ await this.verifyRoot();
238
+ try {
239
+ const manifestPath = join(this.root, 'manifest.json');
240
+ const info = await lstat(manifestPath);
241
+ if (!info.isFile()
242
+ || info.isSymbolicLink()
243
+ || info.size <= 0
244
+ || info.size > MAX_MANIFEST_BYTES
245
+ || (info.mode & 0o022) !== 0) {
246
+ this.fail();
247
+ }
248
+ const parsed = SnapshotManifestSchema.parse(JSON.parse(strictText.decode(await boundedFileBytes(Buffer.from(manifestPath), info.size))));
249
+ const { provenanceDigest, ...withoutDigest } = parsed;
250
+ if (provenanceDigest !== manifestDigest(withoutDigest)
251
+ || provenanceDigest !== this.manifest.provenanceDigest) {
252
+ this.fail();
253
+ }
254
+ return this.manifest;
255
+ }
256
+ catch (error) {
257
+ if (error instanceof PatchSnapshotError)
258
+ throw error;
259
+ this.fail();
260
+ }
261
+ }
262
+ entry(fileId, manifest) {
263
+ if (!fileIdPattern.test(fileId))
264
+ return undefined;
265
+ return manifest.entries.find((entry) => entry.file.id === fileId);
266
+ }
267
+ async readSide(fileId, sideName, side) {
268
+ if (!side.exists)
269
+ return undefined;
270
+ try {
271
+ const contentPath = join(this.root, contentName(fileId, sideName));
272
+ const info = await lstat(contentPath);
273
+ if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o022) !== 0)
274
+ this.fail();
275
+ const bytes = await boundedFileBytes(Buffer.from(contentPath), side.byteLength);
276
+ if (digest(bytes) !== side.sha256)
277
+ this.fail();
278
+ return bytes;
279
+ }
280
+ catch (error) {
281
+ if (error instanceof PatchSnapshotError)
282
+ throw error;
283
+ if (typeof error === 'object' && error !== null && 'code' in error && (error.code === 'ENOENT' || error.code === 'EACCES'))
284
+ this.fail();
285
+ throw new PatchSnapshotError('retryable-read', true);
286
+ }
287
+ }
288
+ async targetDrift(manifest) {
289
+ const expected = await Promise.all(manifest.entries.map(async (entry) => ({
290
+ entry,
291
+ postimage: await this.readSide(entry.file.id, 'postimage', entry.postimage),
292
+ })));
293
+ try {
294
+ const tree = manifest.validationTarget.kind === 'repository'
295
+ ? await repositoryTree(manifest.repositoryIdentity)
296
+ : undefined;
297
+ const reader = tree === undefined ? undefined : createObjectReader(manifest.repositoryIdentity);
298
+ for (const { entry, postimage } of expected) {
299
+ const target = entry.file.newPath ?? entry.file.oldPath;
300
+ if (target === undefined)
301
+ return true;
302
+ let actual;
303
+ if (tree === undefined) {
304
+ actual = await worktreeEntry(manifest.repositoryIdentity, target.bytesBase64url, postimage?.byteLength ?? 0);
305
+ }
306
+ else {
307
+ const metadata = tree.get(target.bytesBase64url);
308
+ if (metadata !== undefined) {
309
+ if (postimage === undefined || metadata.type !== 'blob' || reader === undefined)
310
+ return true;
311
+ const content = await reader.read(metadata.oid, { maxBytes: postimage.byteLength });
312
+ if (content.kind !== 'available')
313
+ return true;
314
+ actual = Object.freeze({ mode: metadata.mode, bytes: Buffer.from(content.bytes) });
315
+ }
316
+ }
317
+ if (postimage === undefined
318
+ ? actual !== undefined
319
+ : actual === undefined
320
+ || actual.mode !== entry.file.newMode
321
+ || !actual.bytes.equals(postimage)) {
322
+ return true;
323
+ }
324
+ const oldPath = entry.file.oldPath;
325
+ const renamed = entry.file.status.kind === 'renamed'
326
+ && oldPath !== undefined
327
+ && oldPath.bytesBase64url !== target.bytesBase64url;
328
+ if (renamed) {
329
+ if (tree?.has(oldPath.bytesBase64url))
330
+ return true;
331
+ if (tree === undefined && await worktreeEntry(manifest.repositoryIdentity, oldPath.bytesBase64url, 0) !== undefined)
332
+ return true;
333
+ }
334
+ }
335
+ return false;
336
+ }
337
+ catch {
338
+ return true;
339
+ }
340
+ }
341
+ async session() {
342
+ const manifest = await this.verifiedManifest();
343
+ return Object.freeze({
344
+ patch: Object.freeze({
345
+ kind: 'exact-patch',
346
+ digest: manifest.digest,
347
+ reviewKey: manifest.reviewKey,
348
+ validationTarget: manifest.validationTarget,
349
+ changedFileCount: manifest.entries.length,
350
+ }),
351
+ files: Object.freeze(manifest.entries.map(({ file }) => ({
352
+ fileId: file.id,
353
+ status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
354
+ oldMode: file.oldMode,
355
+ ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
356
+ ...(file.newPath === undefined ? {} : { newPath: file.newPath }),
357
+ additions: file.additions,
358
+ deletions: file.deletions,
359
+ availability: file.availability,
360
+ }))),
361
+ });
362
+ }
363
+ async lookup(fileId) {
364
+ return this.entry(fileId, await this.verifiedManifest())?.file;
365
+ }
366
+ async readContent(fileId) {
367
+ if (this.unavailable)
368
+ this.fail();
369
+ const entry = this.entry(fileId, await this.verifiedManifest());
370
+ if (entry === undefined || entry.file.availability.kind !== 'text')
371
+ return undefined;
372
+ return Object.freeze({
373
+ file: entry.file,
374
+ preimage: await this.readSide(fileId, 'preimage', entry.preimage),
375
+ postimage: await this.readSide(fileId, 'postimage', entry.postimage),
376
+ });
377
+ }
378
+ /**
379
+ * Reads the materialized, digest-verified preimage bytes for any entry in
380
+ * the manifest, regardless of `availability.kind` (unlike `readContent`,
381
+ * which is scoped to text files for the code-content API). Used by
382
+ * `readRelativeImage` (WR-01) to serve the true base-side bytes of an
383
+ * embedded image that is itself one of the diff's changed files, instead
384
+ * of silently falling back to the live worktree.
385
+ */
386
+ async readPreimageBytes(fileId) {
387
+ if (this.unavailable)
388
+ this.fail();
389
+ const entry = this.entry(fileId, await this.verifiedManifest());
390
+ if (entry === undefined)
391
+ return undefined;
392
+ return this.readSide(fileId, 'preimage', entry.preimage);
393
+ }
394
+ async files() {
395
+ return (await this.verifiedManifest()).entries.map((entry) => entry.file);
396
+ }
397
+ async observe() {
398
+ if (this.unavailable)
399
+ return 'snapshotUnavailable';
400
+ try {
401
+ const manifest = await this.verifiedManifest();
402
+ if (!this.drifted) {
403
+ try {
404
+ this.drifted = this.observeTarget === undefined
405
+ ? await this.targetDrift(manifest)
406
+ : await this.observeTarget();
407
+ }
408
+ catch {
409
+ this.drifted = true;
410
+ }
411
+ }
412
+ return this.drifted ? 'drifted' : 'unchanged';
413
+ }
414
+ catch {
415
+ this.unavailable = true;
416
+ return 'snapshotUnavailable';
417
+ }
418
+ }
419
+ async exportScope() {
420
+ const status = await this.observe();
421
+ if (status === 'snapshotUnavailable')
422
+ this.fail();
423
+ const manifest = await this.verifiedManifest();
424
+ return Object.freeze({
425
+ digest: manifest.digest,
426
+ validationTarget: manifest.validationTarget,
427
+ reviewKey: manifest.reviewKey,
428
+ snapshot: Object.freeze({
429
+ status,
430
+ files: Object.freeze(manifest.entries.map((entry) => entry.file)),
431
+ }),
432
+ });
433
+ }
434
+ async dispose() {
435
+ if (this.disposed)
436
+ return;
437
+ this.disposed = true;
438
+ await rm(this.root, { recursive: true, force: true });
439
+ }
440
+ }
441
+ export async function materializePatchSnapshot(grounded, options = {}) {
442
+ if (grounded.changedFiles.length > MAX_SNAPSHOT_FILES) {
443
+ throw new PatchSnapshotError('snapshot-unavailable', false);
444
+ }
445
+ const parent = options.parent ?? tmpdir();
446
+ const staging = await mkdir(parent, { recursive: true, mode: 0o700 }).then(() => join(parent, `.${RENAME_MAP.slug}-patch-stage-${randomUUID()}`));
447
+ const accepted = join(parent, `${RENAME_MAP.slug}-patch-${randomUUID()}`);
448
+ let total = 0;
449
+ try {
450
+ await mkdir(staging, { mode: 0o700 });
451
+ const entries = [];
452
+ for (const file of grounded.changedFiles) {
453
+ if (!fileIdPattern.test(file.id))
454
+ throw new PatchSnapshotError('snapshot-unavailable', false);
455
+ const content = grounded.contents.get(file.id);
456
+ if (content === undefined)
457
+ throw new PatchSnapshotError('snapshot-unavailable', false);
458
+ const preimage = Buffer.from(content.preimage ?? []);
459
+ const postimage = Buffer.from(content.postimage ?? []);
460
+ total += preimage.byteLength + postimage.byteLength;
461
+ if (preimage.byteLength > MAX_SIDE_BYTES || postimage.byteLength > MAX_SIDE_BYTES || total > MAX_SNAPSHOT_BYTES)
462
+ throw new PatchSnapshotError('snapshot-unavailable', false);
463
+ const frozenFile = ChangedFileSchema.parse(structuredClone(file));
464
+ const entry = SnapshotEntrySchema.parse({
465
+ file: frozenFile,
466
+ preimage: side(content.preimage, frozenFile.oldPath, frozenFile.oldMode, frozenFile.oldBlobOid),
467
+ postimage: side(content.postimage, frozenFile.newPath, frozenFile.newMode, frozenFile.newBlobOid),
468
+ });
469
+ entries.push(entry);
470
+ if (content.preimage !== undefined)
471
+ await writeOwned(join(staging, contentName(file.id, 'preimage')), preimage);
472
+ if (content.postimage !== undefined)
473
+ await writeOwned(join(staging, contentName(file.id, 'postimage')), postimage);
474
+ }
475
+ const base = {
476
+ version: SNAPSHOT_VERSION,
477
+ digest: grounded.scope.digest,
478
+ validationTarget: Object.freeze({ ...grounded.scope.validationTarget }),
479
+ reviewKey: reviewKey(grounded),
480
+ submittedByteLength: grounded.scope.submittedByteLength,
481
+ repositoryIdentity: grounded.repositoryRoot,
482
+ objectFormat: grounded.objectFormat,
483
+ entries: Object.freeze(entries),
484
+ };
485
+ const manifest = SnapshotManifestSchema.parse({ ...base, provenanceDigest: manifestDigest(base) });
486
+ await writeOwned(join(staging, 'manifest.json'), Buffer.from(JSON.stringify(manifest), 'utf8'));
487
+ await syncDirectory(staging);
488
+ await rename(staging, accepted);
489
+ await chmod(accepted, 0o700);
490
+ await syncDirectory(parent);
491
+ return new PatchSnapshot(manifest, accepted, options.observeTarget);
492
+ }
493
+ catch (error) {
494
+ await rm(staging, { recursive: true, force: true });
495
+ await rm(accepted, { recursive: true, force: true });
496
+ if (error instanceof PatchSnapshotError)
497
+ throw error;
498
+ throw new PatchSnapshotError('snapshot-unavailable', false);
499
+ }
500
+ }