@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.
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,391 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import { ChangedFileSchema } from '../contracts/comparison.js';
4
+ import { createExactPath, decodeBase64url } from '../domain/path-bytes.js';
5
+ import { MAX_INLINE_TEXT_BYTES, classifyAvailability } from './availability.js';
6
+ import { inventoryPaths, isLoopnoteInternalPath, opaqueFileId, validateObjectFormat, } from './inventory.js';
7
+ import { createObjectReader } from './objects.js';
8
+ import { joinDiffStats, parseNumstat } from './numstat.js';
9
+ import { parseRawDiff } from './raw-diff.js';
10
+ import { createGitRunner } from './runner.js';
11
+ const emptyPathspecs = Object.freeze([]);
12
+ const processFileIdNamespace = randomBytes(32);
13
+ const knownModes = Object.freeze({
14
+ '000000': true,
15
+ '040000': true,
16
+ '100644': true,
17
+ '100755': true,
18
+ '120000': true,
19
+ '160000': true,
20
+ });
21
+ const statusKindByCode = Object.freeze({
22
+ A: 'added',
23
+ C: 'copied',
24
+ D: 'deleted',
25
+ M: 'modified',
26
+ R: 'renamed',
27
+ T: 'type-changed',
28
+ });
29
+ const regularFileModes = Object.freeze({
30
+ '100644': true,
31
+ '100755': true,
32
+ });
33
+ const sharedDiffOptions = [
34
+ '-z',
35
+ '--find-renames=50%',
36
+ '--find-copies=50%',
37
+ '--find-copies-harder',
38
+ '--no-ext-diff',
39
+ '--no-textconv',
40
+ ];
41
+ function filterLoopnoteInternal(joined) {
42
+ return joined.filter(({ diff }) => {
43
+ const { oldPath, newPath } = inventoryPaths(diff);
44
+ return !isLoopnoteInternalPath(oldPath) && !isLoopnoteInternalPath(newPath);
45
+ });
46
+ }
47
+ async function buildChangedFile(diff, stats, objectFormat, namespace, classify) {
48
+ validateObjectFormat(diff, objectFormat);
49
+ const knownStatus = statusKindByCode[diff.status];
50
+ const hasUnsupportedMetadata = knownStatus === undefined ||
51
+ knownModes[diff.oldMode] !== true ||
52
+ knownModes[diff.newMode] !== true;
53
+ const statusKind = hasUnsupportedMetadata
54
+ ? 'unsupported'
55
+ : knownStatus;
56
+ const file = {
57
+ id: opaqueFileId(namespace, diff),
58
+ status: {
59
+ code: diff.status,
60
+ kind: statusKind,
61
+ similarity: diff.similarity,
62
+ },
63
+ oldMode: diff.oldMode,
64
+ newMode: diff.newMode,
65
+ oldBlobOid: diff.oldBlobOid,
66
+ newBlobOid: diff.newBlobOid,
67
+ ...inventoryPaths(diff),
68
+ additions: stats.additions,
69
+ deletions: stats.deletions,
70
+ };
71
+ const availability = await classify(file);
72
+ return ChangedFileSchema.parse({ ...file, availability });
73
+ }
74
+ /**
75
+ * Walks each path segment with `lstat`, rejecting any intermediate component
76
+ * that is a symlink (or not a directory) before resolving the leaf — the same
77
+ * per-segment traversal discipline as `PatchSnapshot.worktreeEntry()`
78
+ * (`src/server/patch-snapshot.ts:186-216`), duplicated here (not imported)
79
+ * because `src/git/` never depends on `src/server/`.
80
+ */
81
+ async function lstatWorktreeLeaf(repositoryRoot, path) {
82
+ const parts = Buffer.from(decodeBase64url(path.bytesBase64url))
83
+ .toString('latin1')
84
+ .split('/');
85
+ let current = Buffer.from(repositoryRoot, 'utf8');
86
+ try {
87
+ for (const [index, part] of parts.entries()) {
88
+ current = Buffer.concat([
89
+ current,
90
+ Buffer.from('/', 'ascii'),
91
+ Buffer.from(part, 'latin1'),
92
+ ]);
93
+ const info = await lstat(current);
94
+ if (index < parts.length - 1) {
95
+ if (info.isSymbolicLink() || !info.isDirectory()) {
96
+ return { kind: 'missing' };
97
+ }
98
+ continue;
99
+ }
100
+ if (info.isSymbolicLink()) {
101
+ return { kind: 'symlink' };
102
+ }
103
+ if (!info.isFile()) {
104
+ return { kind: 'missing' };
105
+ }
106
+ return {
107
+ kind: 'file',
108
+ size: info.size,
109
+ executable: (info.mode & 0o111) !== 0,
110
+ };
111
+ }
112
+ return { kind: 'missing' };
113
+ }
114
+ catch (error) {
115
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
116
+ return { kind: 'missing' };
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+ /**
122
+ * Bounded read-then-verify: open the resolved leaf path, confirm it is still
123
+ * a regular file of exactly `expectedSize` bytes, and read no more than that
124
+ * (via the `expectedSize + 1` over-read trick to detect concurrent growth) —
125
+ * identical discipline to `boundedFileBytes()` (`src/server/snapshot-fs.ts`),
126
+ * duplicated here for the same layering reason as `lstatWorktreeLeaf`.
127
+ */
128
+ async function readWorktreeLeafBytes(repositoryRoot, path, expectedSize) {
129
+ const absolutePath = Buffer.concat([
130
+ Buffer.from(repositoryRoot, 'utf8'),
131
+ Buffer.from('/', 'ascii'),
132
+ Buffer.from(decodeBase64url(path.bytesBase64url)),
133
+ ]);
134
+ let handle;
135
+ try {
136
+ handle = await open(absolutePath, 'r');
137
+ }
138
+ catch {
139
+ return undefined;
140
+ }
141
+ try {
142
+ const info = await handle.stat();
143
+ if (!info.isFile() || info.size !== expectedSize) {
144
+ return undefined;
145
+ }
146
+ const bytes = Buffer.allocUnsafe(expectedSize + 1);
147
+ let offset = 0;
148
+ while (offset < bytes.byteLength) {
149
+ const { bytesRead } = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
150
+ if (bytesRead === 0) {
151
+ break;
152
+ }
153
+ offset += bytesRead;
154
+ }
155
+ if (offset !== expectedSize) {
156
+ return undefined;
157
+ }
158
+ return bytes.subarray(0, expectedSize);
159
+ }
160
+ finally {
161
+ await handle.close();
162
+ }
163
+ }
164
+ /**
165
+ * Mirrors `classifyAvailability()`'s cascade (mode/status checks, size
166
+ * ceiling, numstat binary flag, embedded-NUL check, strict UTF-8 decode) but
167
+ * substitutes a bounded `fs` read for the one side that is worktree-resident
168
+ * (not a Git object) instead of going through `ObjectReader`. The other side
169
+ * (when present) still goes through `objectReader` unchanged. `hasNumstat`
170
+ * is `false` for untracked files, which have no numstat line at all (their
171
+ * `additions`/`deletions` are always `null` by construction, not a binary
172
+ * signal) — those fall through to the real embedded-NUL content check instead.
173
+ */
174
+ async function classifyWorktreeSideAvailability(file, objectReader, worktreeSide, repositoryRoot, signal, hasNumstat = true) {
175
+ if (file.oldMode === '160000' || file.newMode === '160000') {
176
+ return { kind: 'unsupported', reason: 'submodule' };
177
+ }
178
+ if (file.oldMode === '120000' || file.newMode === '120000') {
179
+ return { kind: 'unsupported', reason: 'symlink' };
180
+ }
181
+ if (file.status.kind === 'unsupported') {
182
+ return { kind: 'unsupported', reason: 'mode-or-type' };
183
+ }
184
+ const sides = [];
185
+ if (file.oldMode !== '000000') {
186
+ if (regularFileModes[file.oldMode] !== true) {
187
+ return { kind: 'unsupported', reason: 'mode-or-type' };
188
+ }
189
+ sides.push({ side: 'old', mode: file.oldMode, oid: file.oldBlobOid });
190
+ }
191
+ if (file.newMode !== '000000') {
192
+ if (regularFileModes[file.newMode] !== true) {
193
+ return { kind: 'unsupported', reason: 'mode-or-type' };
194
+ }
195
+ sides.push({ side: 'new', mode: file.newMode, oid: file.newBlobOid });
196
+ }
197
+ if (sides.length === 0) {
198
+ return { kind: 'unsupported', reason: 'mode-or-type' };
199
+ }
200
+ const sizes = [];
201
+ for (const side of sides) {
202
+ if (side.side === worktreeSide) {
203
+ const path = worktreeSide === 'new' ? file.newPath : file.oldPath;
204
+ if (path === undefined) {
205
+ return { kind: 'unavailable', reason: 'missing-object' };
206
+ }
207
+ const leaf = await lstatWorktreeLeaf(repositoryRoot, path);
208
+ if (leaf.kind !== 'file') {
209
+ return { kind: 'unavailable', reason: 'missing-object' };
210
+ }
211
+ sizes.push(leaf.size);
212
+ }
213
+ else {
214
+ const metadata = await objectReader.inspect(side.oid, signal);
215
+ if (metadata.kind === 'missing' || metadata.objectType !== 'blob') {
216
+ return { kind: 'unavailable', reason: 'missing-object' };
217
+ }
218
+ sizes.push(metadata.size);
219
+ }
220
+ }
221
+ if (sizes.some((size) => size > MAX_INLINE_TEXT_BYTES)) {
222
+ return { kind: 'unsupported', reason: 'oversized' };
223
+ }
224
+ if (hasNumstat && (file.additions === null || file.deletions === null)) {
225
+ return { kind: 'unsupported', reason: 'binary' };
226
+ }
227
+ const contents = [];
228
+ for (let index = 0; index < sides.length; index += 1) {
229
+ const side = sides[index];
230
+ if (side.side === worktreeSide) {
231
+ const path = worktreeSide === 'new' ? file.newPath : file.oldPath;
232
+ const bytes = path === undefined
233
+ ? undefined
234
+ : await readWorktreeLeafBytes(repositoryRoot, path, sizes[index]);
235
+ if (bytes === undefined) {
236
+ return { kind: 'unavailable', reason: 'missing-object' };
237
+ }
238
+ contents.push(bytes);
239
+ }
240
+ else {
241
+ const content = await objectReader.read(side.oid, {
242
+ maxBytes: MAX_INLINE_TEXT_BYTES,
243
+ signal,
244
+ });
245
+ if (content.kind === 'missing' ||
246
+ content.bytes.byteLength !== sizes[index]) {
247
+ return { kind: 'unavailable', reason: 'missing-object' };
248
+ }
249
+ contents.push(content.bytes);
250
+ }
251
+ }
252
+ if (contents.some((content) => content.includes(0))) {
253
+ return { kind: 'unsupported', reason: 'binary' };
254
+ }
255
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
256
+ try {
257
+ for (const content of contents) {
258
+ utf8Decoder.decode(content);
259
+ }
260
+ }
261
+ catch {
262
+ return { kind: 'unsupported', reason: 'non-utf8' };
263
+ }
264
+ return { kind: 'text' };
265
+ }
266
+ function parseNulDelimitedUntrackedPaths(output) {
267
+ const paths = [];
268
+ let offset = 0;
269
+ while (offset < output.length) {
270
+ const end = output.indexOf(0, offset);
271
+ if (end === -1) {
272
+ break;
273
+ }
274
+ if (end > offset) {
275
+ paths.push(createExactPath(output.subarray(offset, end)));
276
+ }
277
+ offset = end + 1;
278
+ }
279
+ return Object.freeze(paths);
280
+ }
281
+ export async function buildUntrackedChangedFile(path, objectFormat, namespace, objectReader, repositoryRoot, signal) {
282
+ const leaf = await lstatWorktreeLeaf(repositoryRoot, path);
283
+ const newMode = leaf.kind === 'symlink'
284
+ ? '120000'
285
+ : leaf.kind === 'file' && leaf.executable
286
+ ? '100755'
287
+ : '100644';
288
+ const zeroOid = '0'.repeat(objectFormat === 'sha1' ? 40 : 64);
289
+ const diff = Object.freeze({
290
+ oldMode: '000000',
291
+ newMode,
292
+ oldBlobOid: zeroOid,
293
+ newBlobOid: zeroOid,
294
+ status: 'A',
295
+ similarity: null,
296
+ paths: Object.freeze([path]),
297
+ });
298
+ return buildChangedFile(diff, { additions: null, deletions: null }, objectFormat, namespace, (file) => classifyWorktreeSideAvailability(file, objectReader, 'new', repositoryRoot, signal, false));
299
+ }
300
+ export async function createWorktreeChangedFileInventory(options, dependencies = {}) {
301
+ const pathspecTail = Object.freeze([
302
+ '--',
303
+ ...(options.pathspecs ?? emptyPathspecs),
304
+ ]);
305
+ const runner = dependencies.runner ?? createGitRunner();
306
+ const objectReader = dependencies.objectReader ??
307
+ createObjectReader(options.repositoryRoot, { runner });
308
+ const namespace = options.fileIdNamespace ?? processFileIdNamespace;
309
+ if (namespace.byteLength === 0) {
310
+ throw new RangeError('File ID namespace must not be empty');
311
+ }
312
+ if (options.sourceKind === 'staged') {
313
+ const mergeBaseOid = options.mergeBaseOid;
314
+ if (mergeBaseOid === undefined) {
315
+ throw new RangeError('mergeBaseOid is required when sourceKind is "staged"');
316
+ }
317
+ const [rawResult, numstatResult] = await Promise.all([
318
+ runner.run([
319
+ 'diff',
320
+ '--cached',
321
+ '--raw',
322
+ sharedDiffOptions[0],
323
+ '--no-abbrev',
324
+ ...sharedDiffOptions.slice(1),
325
+ mergeBaseOid,
326
+ ...pathspecTail,
327
+ ], { cwd: options.repositoryRoot, signal: options.signal }),
328
+ runner.run([
329
+ 'diff',
330
+ '--cached',
331
+ '--numstat',
332
+ ...sharedDiffOptions,
333
+ mergeBaseOid,
334
+ ...pathspecTail,
335
+ ], { cwd: options.repositoryRoot, signal: options.signal }),
336
+ ]);
337
+ const joined = joinDiffStats(parseRawDiff(rawResult.stdout), parseNumstat(numstatResult.stdout));
338
+ const reviewable = filterLoopnoteInternal(joined);
339
+ const files = await Promise.all(reviewable.map(({ diff, stats }) => buildChangedFile(diff, stats, options.objectFormat, namespace, (file) => classifyAvailability(file, objectReader, options.signal))));
340
+ return Object.freeze(files);
341
+ }
342
+ if (options.sourceKind === 'unstaged') {
343
+ const [rawResult, numstatResult] = await Promise.all([
344
+ runner.run([
345
+ 'diff',
346
+ '--raw',
347
+ sharedDiffOptions[0],
348
+ '--no-abbrev',
349
+ ...sharedDiffOptions.slice(1),
350
+ ...pathspecTail,
351
+ ], { cwd: options.repositoryRoot, signal: options.signal }),
352
+ runner.run(['diff', '--numstat', ...sharedDiffOptions, ...pathspecTail], { cwd: options.repositoryRoot, signal: options.signal }),
353
+ ]);
354
+ const joined = joinDiffStats(parseRawDiff(rawResult.stdout), parseNumstat(numstatResult.stdout));
355
+ const reviewable = filterLoopnoteInternal(joined);
356
+ const files = await Promise.all(reviewable.map(({ diff, stats }) => buildChangedFile(diff, stats, options.objectFormat, namespace, (file) => classifyWorktreeSideAvailability(file, objectReader, 'new', options.repositoryRoot, options.signal))));
357
+ return Object.freeze(files);
358
+ }
359
+ if (options.sourceKind === 'worktree') {
360
+ const mergeBaseOid = options.mergeBaseOid;
361
+ if (mergeBaseOid === undefined) {
362
+ throw new RangeError('mergeBaseOid is required when sourceKind is "worktree"');
363
+ }
364
+ const [rawResult, numstatResult, untrackedResult] = await Promise.all([
365
+ runner.run([
366
+ 'diff',
367
+ '--raw',
368
+ sharedDiffOptions[0],
369
+ '--no-abbrev',
370
+ ...sharedDiffOptions.slice(1),
371
+ mergeBaseOid,
372
+ ...pathspecTail,
373
+ ], { cwd: options.repositoryRoot, signal: options.signal }),
374
+ runner.run([
375
+ 'diff',
376
+ '--numstat',
377
+ ...sharedDiffOptions,
378
+ mergeBaseOid,
379
+ ...pathspecTail,
380
+ ], { cwd: options.repositoryRoot, signal: options.signal }),
381
+ runner.run(['ls-files', '--others', '--exclude-standard', '-z', ...pathspecTail], { cwd: options.repositoryRoot, signal: options.signal }),
382
+ ]);
383
+ const joined = joinDiffStats(parseRawDiff(rawResult.stdout), parseNumstat(numstatResult.stdout));
384
+ const reviewable = filterLoopnoteInternal(joined);
385
+ const trackedFiles = await Promise.all(reviewable.map(({ diff, stats }) => buildChangedFile(diff, stats, options.objectFormat, namespace, (file) => classifyWorktreeSideAvailability(file, objectReader, 'new', options.repositoryRoot, options.signal))));
386
+ const untrackedPaths = parseNulDelimitedUntrackedPaths(untrackedResult.stdout).filter((path) => !isLoopnoteInternalPath(path));
387
+ const untrackedFiles = await Promise.all(untrackedPaths.map((path) => buildUntrackedChangedFile(path, options.objectFormat, namespace, objectReader, options.repositoryRoot, options.signal)));
388
+ return Object.freeze([...trackedFiles, ...untrackedFiles]);
389
+ }
390
+ throw new RangeError('Unreachable: sourceKind must be staged, unstaged, or worktree');
391
+ }
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import * as fs from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import fastifyStatic from '@fastify/static';
6
+ import Fastify from 'fastify';
7
+ import { createCapabilityRegistry, createExactPatchCapabilityRegistry, createUncommittedCapabilityRegistry, createStandaloneCapabilityRegistry, createSupportCapability, } from './capabilities.js';
8
+ import { createHostedSupportClient } from './support-client.js';
9
+ import { createSupportStore } from './support-store.js';
10
+ import { createDraftStore, } from './draft-store.js';
11
+ import { registerSessionRoutes } from './routes.js';
12
+ import { materializePatchSnapshot } from './patch-snapshot.js';
13
+ import { materializeWorktreeSnapshot } from './worktree-snapshot.js';
14
+ import { registerSessionSecurity, } from './security.js';
15
+ import { RENAME_MAP } from '../domain/product-identity.js';
16
+ export function createConfiguredSupportCapability(environment = process.env) {
17
+ const serviceUrl = environment[`${RENAME_MAP.envPrefix}SUPPORT_SERVICE_URL`];
18
+ try {
19
+ if (serviceUrl === undefined || new URL(serviceUrl).protocol !== 'https:')
20
+ return undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ return createSupportCapability(createSupportStore(), createHostedSupportClient({ serviceUrl }));
26
+ }
27
+ function testRecoveryFailure() {
28
+ if (process.env.NODE_ENV !== 'test') {
29
+ return undefined;
30
+ }
31
+ const configured = process.env[`${RENAME_MAP.envPrefix}TEST_RECOVERY_FAILURE`];
32
+ return configured === 'backup' || configured === 'replacement' ? configured : undefined;
33
+ }
34
+ function createRecoveryFaultFileSystem(failure) {
35
+ return {
36
+ readFile: async (path) => fs.readFile(path),
37
+ mkdir: async (path) => {
38
+ await fs.mkdir(path, { recursive: true });
39
+ },
40
+ open: async (path, flags, mode) => {
41
+ if ((failure === 'backup' && path.endsWith('.bak')) ||
42
+ (failure === 'replacement' && path.endsWith('.tmp'))) {
43
+ throw new Error('Test-only recovery persistence failure.');
44
+ }
45
+ const handle = await fs.open(path, flags, mode);
46
+ return {
47
+ writeFile: async (bytes) => handle.writeFile(bytes),
48
+ sync: async () => handle.sync(),
49
+ close: async () => handle.close(),
50
+ };
51
+ },
52
+ rename: async (from, to) => fs.rename(from, to),
53
+ unlink: async (path) => fs.unlink(path),
54
+ syncDirectory: async (path) => {
55
+ const handle = await fs.open(path, 'r');
56
+ try {
57
+ await handle.sync();
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ },
63
+ };
64
+ }
65
+ function createAppDraftStore(comparison, options) {
66
+ if (options.draftStore !== undefined) {
67
+ return options.draftStore;
68
+ }
69
+ const failure = testRecoveryFailure();
70
+ return createDraftStore({
71
+ repositoryRoot: comparison.repositoryRoot,
72
+ comparison: comparison.range === undefined
73
+ ? {
74
+ baseCommitOid: comparison.base.oid,
75
+ headCommitOid: comparison.head.oid,
76
+ mergeBaseOid: comparison.mergeBaseOid,
77
+ }
78
+ : {
79
+ baseCommitOid: comparison.base.oid,
80
+ headCommitOid: comparison.head.oid,
81
+ mergeBaseOid: comparison.mergeBaseOid,
82
+ range: comparison.range,
83
+ },
84
+ ...(options.attachedCompletion === undefined ? {} : { storageScope: options.attachedCompletion.storageScope }),
85
+ ...(failure === undefined ? {} : { fileSystem: createRecoveryFaultFileSystem(failure) }),
86
+ });
87
+ }
88
+ export function createSessionApp(comparison, options) {
89
+ const app = Fastify({
90
+ logger: false,
91
+ ajv: { customOptions: { removeAdditional: false } },
92
+ });
93
+ const support = options.support ?? createConfiguredSupportCapability();
94
+ const webRoot = options.webRoot ?? resolve(import.meta.dirname, '../web');
95
+ const draftStore = createAppDraftStore(comparison, options);
96
+ const capabilities = createCapabilityRegistry(comparison, { ...options, draftStore, support });
97
+ const security = registerSessionSecurity(app, options);
98
+ app.decorate('bindSessionSecurity', security.bind);
99
+ app.addHook('onClose', async () => support?.close());
100
+ registerSessionRoutes(app, capabilities);
101
+ void app.register(fastifyStatic, {
102
+ root: webRoot,
103
+ index: ['index.html'],
104
+ });
105
+ return app;
106
+ }
107
+ export async function createExactPatchSessionApp(grounded, options) {
108
+ const snapshot = await materializePatchSnapshot(grounded, {
109
+ parent: options.snapshotParent,
110
+ observeTarget: options.observePatchTarget,
111
+ });
112
+ const app = Fastify({
113
+ logger: false,
114
+ ajv: { customOptions: { removeAdditional: false } },
115
+ });
116
+ const webRoot = options.webRoot ?? resolve(import.meta.dirname, '../web');
117
+ const support = options.support ?? createConfiguredSupportCapability();
118
+ const capabilities = await createExactPatchCapabilityRegistry(grounded, snapshot, { ...options, support });
119
+ const security = registerSessionSecurity(app, options);
120
+ app.addHook('onClose', async () => support?.close());
121
+ app.decorate('bindSessionSecurity', security.bind);
122
+ app.addHook('onClose', async () => snapshot.dispose());
123
+ registerSessionRoutes(app, capabilities);
124
+ void app.register(fastifyStatic, {
125
+ root: webRoot,
126
+ index: ['index.html'],
127
+ });
128
+ return app;
129
+ }
130
+ export async function createUncommittedSessionApp(scope, options) {
131
+ const snapshot = await materializeWorktreeSnapshot(scope, {
132
+ root: options.snapshotRoot ?? join(tmpdir(), `${RENAME_MAP.slug}-worktree-${randomUUID()}`),
133
+ });
134
+ const app = Fastify({
135
+ logger: false,
136
+ ajv: { customOptions: { removeAdditional: false } },
137
+ });
138
+ const webRoot = options.webRoot ?? resolve(import.meta.dirname, '../web');
139
+ const support = options.support ?? createConfiguredSupportCapability();
140
+ const capabilities = await createUncommittedCapabilityRegistry(scope, snapshot, { ...options, support });
141
+ const security = registerSessionSecurity(app, options);
142
+ app.addHook('onClose', async () => support?.close());
143
+ app.decorate('bindSessionSecurity', security.bind);
144
+ registerSessionRoutes(app, capabilities);
145
+ void app.register(fastifyStatic, {
146
+ root: webRoot,
147
+ index: ['index.html'],
148
+ });
149
+ return app;
150
+ }
151
+ export async function createStandaloneSessionApp(scope, options) {
152
+ const snapshot = await materializeWorktreeSnapshot(scope, {
153
+ root: options.snapshotRoot ?? join(tmpdir(), `${RENAME_MAP.slug}-worktree-${randomUUID()}`),
154
+ });
155
+ const app = Fastify({
156
+ logger: false,
157
+ ajv: { customOptions: { removeAdditional: false } },
158
+ });
159
+ const webRoot = options.webRoot ?? resolve(import.meta.dirname, '../web');
160
+ const support = options.support ?? createConfiguredSupportCapability();
161
+ const capabilities = await createStandaloneCapabilityRegistry(scope, snapshot, { ...options, support });
162
+ const security = registerSessionSecurity(app, options);
163
+ app.addHook('onClose', async () => support?.close());
164
+ app.decorate('bindSessionSecurity', security.bind);
165
+ registerSessionRoutes(app, capabilities);
166
+ void app.register(fastifyStatic, {
167
+ root: webRoot,
168
+ index: ['index.html'],
169
+ });
170
+ return app;
171
+ }
@@ -0,0 +1,85 @@
1
+ import { AttachedCompletionStatusSchema, FinishReviewResultSchema, } from '../contracts/api.js';
2
+ export class AttachedCompletionCoordinator {
3
+ #status = AttachedCompletionStatusSchema.parse({ kind: 'waiting' });
4
+ #inFlight;
5
+ #terminal;
6
+ #resolveDelivery;
7
+ #resolveResponseSettled;
8
+ delivery;
9
+ responseSettled;
10
+ constructor() {
11
+ const delivery = Promise.withResolvers();
12
+ this.delivery = delivery.promise;
13
+ this.#resolveDelivery = delivery.resolve;
14
+ const responseSettled = Promise.withResolvers();
15
+ this.responseSettled = responseSettled.promise;
16
+ this.#resolveResponseSettled = responseSettled.resolve;
17
+ }
18
+ markResponseSettled() {
19
+ if (this.#status.kind === 'completed')
20
+ this.#resolveResponseSettled();
21
+ }
22
+ cancel() {
23
+ if (this.#status.kind === 'completed' || this.#terminal !== undefined)
24
+ return;
25
+ const result = FinishReviewResultSchema.parse({ kind: 'deliveryFailed' });
26
+ this.#terminal = result;
27
+ this.#resolveDelivery(result);
28
+ }
29
+ async runDelivery(operation) {
30
+ if (this.#status.kind === 'completed' || this.#terminal !== undefined)
31
+ return false;
32
+ await operation();
33
+ return true;
34
+ }
35
+ status() {
36
+ return this.#status;
37
+ }
38
+ async finish(expectedRevision, operation) {
39
+ if (this.#status.kind === 'completed') {
40
+ return FinishReviewResultSchema.parse({ kind: 'alreadyCompleted', revision: this.#status.revision });
41
+ }
42
+ if (this.#terminal !== undefined) {
43
+ return this.#terminal;
44
+ }
45
+ if (this.#inFlight !== undefined) {
46
+ return this.#inFlight;
47
+ }
48
+ this.#status = AttachedCompletionStatusSchema.parse({ kind: 'finishing', expectedRevision });
49
+ const attempt = (async () => {
50
+ let result;
51
+ try {
52
+ result = FinishReviewResultSchema.parse(await operation());
53
+ }
54
+ catch {
55
+ result = FinishReviewResultSchema.parse({ kind: 'deliveryFailed' });
56
+ }
57
+ if (this.#terminal !== undefined)
58
+ return this.#terminal;
59
+ if (result.kind === 'completed') {
60
+ this.#status = AttachedCompletionStatusSchema.parse({ kind: 'completed', revision: result.revision });
61
+ this.#resolveDelivery(result);
62
+ }
63
+ else if (result.kind === 'deliveryFailed') {
64
+ this.#terminal = result;
65
+ this.#resolveDelivery(result);
66
+ }
67
+ else {
68
+ this.#status = AttachedCompletionStatusSchema.parse({ kind: 'waiting' });
69
+ }
70
+ return result;
71
+ })();
72
+ this.#inFlight = attempt;
73
+ try {
74
+ return await attempt;
75
+ }
76
+ finally {
77
+ if (this.#inFlight === attempt && this.#terminal === undefined && this.#status.kind !== 'finishing') {
78
+ this.#inFlight = undefined;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ export function createAttachedCompletionCoordinator() {
84
+ return new AttachedCompletionCoordinator();
85
+ }