@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.
- 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,134 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CURRENT_DRAFT_SCHEMA_VERSION, DraftVersionEnvelopeSchema, ReviewDraftV1Schema, } from '../contracts/draft.js';
|
|
5
|
+
import { comparisonKey } from '../domain/comparison-key.js';
|
|
6
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
7
|
+
const draftsDirectory = `${RENAME_MAP.repoStateDir}drafts`;
|
|
8
|
+
const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
|
|
9
|
+
export function assertAttachedStorageScope(storageScope) {
|
|
10
|
+
if (!/^agent-[0-9a-f]{32}$/u.test(storageScope)) {
|
|
11
|
+
throw new Error('Attached storage scope is invalid.');
|
|
12
|
+
}
|
|
13
|
+
return storageScope;
|
|
14
|
+
}
|
|
15
|
+
function isMissing(error) {
|
|
16
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
17
|
+
}
|
|
18
|
+
function bounded(value) {
|
|
19
|
+
if (value.length <= 160) {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
return `${value.slice(0, 157)}...`;
|
|
23
|
+
}
|
|
24
|
+
function fingerprint(raw) {
|
|
25
|
+
return createHash('sha256').update(raw).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
function sameComparison(left, right) {
|
|
28
|
+
if (('kind' in left && left.kind === 'exact-patch') || ('kind' in right && right.kind === 'exact-patch')) {
|
|
29
|
+
return ('kind' in left &&
|
|
30
|
+
'kind' in right &&
|
|
31
|
+
left.kind === 'exact-patch' &&
|
|
32
|
+
right.kind === 'exact-patch' &&
|
|
33
|
+
left.digest === right.digest &&
|
|
34
|
+
left.validationTarget.kind === right.validationTarget.kind &&
|
|
35
|
+
left.reviewKey === right.reviewKey);
|
|
36
|
+
}
|
|
37
|
+
if (('kind' in left && left.kind === 'uncommitted') || ('kind' in right && right.kind === 'uncommitted')) {
|
|
38
|
+
return ('kind' in left &&
|
|
39
|
+
'kind' in right &&
|
|
40
|
+
left.kind === 'uncommitted' &&
|
|
41
|
+
right.kind === 'uncommitted' &&
|
|
42
|
+
left.sourceKind === right.sourceKind &&
|
|
43
|
+
left.baseCommitOid === right.baseCommitOid &&
|
|
44
|
+
left.reviewKey === right.reviewKey);
|
|
45
|
+
}
|
|
46
|
+
if (left.baseCommitOid !== right.baseCommitOid ||
|
|
47
|
+
left.headCommitOid !== right.headCommitOid ||
|
|
48
|
+
left.mergeBaseOid !== right.mergeBaseOid) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (left.range === undefined || right.range === undefined) {
|
|
52
|
+
return left.range === right.range;
|
|
53
|
+
}
|
|
54
|
+
return (left.range.requestedBase === right.range.requestedBase &&
|
|
55
|
+
left.range.requestedHead === right.range.requestedHead &&
|
|
56
|
+
left.range.baseOid === right.range.baseOid &&
|
|
57
|
+
left.range.headOid === right.range.headOid &&
|
|
58
|
+
left.range.reviewKey === right.range.reviewKey &&
|
|
59
|
+
left.range.pathspecs.length === right.range.pathspecs.length &&
|
|
60
|
+
left.range.pathspecs.every((pathspec, index) => pathspec === right.range?.pathspecs[index]));
|
|
61
|
+
}
|
|
62
|
+
function invalidState(path, raw, details) {
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
kind: 'schemaInvalid',
|
|
65
|
+
path,
|
|
66
|
+
raw,
|
|
67
|
+
fingerprint: fingerprint(raw),
|
|
68
|
+
details: Object.freeze(details.slice(0, 8)),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export function draftPaths(repositoryRoot, comparison, storageScope) {
|
|
72
|
+
const key = storageScope === undefined
|
|
73
|
+
? ('kind' in comparison
|
|
74
|
+
? comparison.reviewKey
|
|
75
|
+
: comparison.range?.reviewKey ?? comparisonKey(comparison.baseCommitOid, comparison.headCommitOid))
|
|
76
|
+
: assertAttachedStorageScope(storageScope);
|
|
77
|
+
const relativePath = `${draftsDirectory}/${key}.json`;
|
|
78
|
+
const directory = join(repositoryRoot, draftsDirectory);
|
|
79
|
+
return Object.freeze({ key, directory, canonicalPath: join(repositoryRoot, relativePath), relativePath });
|
|
80
|
+
}
|
|
81
|
+
export function classifyDraft(raw, path, comparison) {
|
|
82
|
+
let value;
|
|
83
|
+
try {
|
|
84
|
+
value = JSON.parse(strictUtf8.decode(raw));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
kind: 'malformed',
|
|
89
|
+
path,
|
|
90
|
+
raw,
|
|
91
|
+
fingerprint: fingerprint(raw),
|
|
92
|
+
detail: Object.freeze({ message: 'Draft is not valid UTF-8 JSON.' }),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const envelope = DraftVersionEnvelopeSchema.safeParse(value);
|
|
96
|
+
if (!envelope.success) {
|
|
97
|
+
return invalidState(path, raw, envelope.error.issues.map((issue) => Object.freeze({ path: bounded(issue.path.join('.')), message: bounded(issue.message) })));
|
|
98
|
+
}
|
|
99
|
+
if (envelope.data.schemaVersion > CURRENT_DRAFT_SCHEMA_VERSION) {
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
kind: 'newerUnsupported',
|
|
102
|
+
path,
|
|
103
|
+
raw,
|
|
104
|
+
foundVersion: envelope.data.schemaVersion,
|
|
105
|
+
supportedVersion: CURRENT_DRAFT_SCHEMA_VERSION,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const parsed = ReviewDraftV1Schema.safeParse(value);
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
return invalidState(path, raw, parsed.error.issues.map((issue) => Object.freeze({ path: bounded(issue.path.join('.')), message: bounded(issue.message) })));
|
|
111
|
+
}
|
|
112
|
+
if (!sameComparison(parsed.data.comparison, comparison)) {
|
|
113
|
+
return invalidState(path, raw, [Object.freeze({ path: 'comparison', message: 'Draft comparison does not match this session.' })]);
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze({ kind: 'current', path, raw, draft: parsed.data });
|
|
116
|
+
}
|
|
117
|
+
export function createDraftLoader(options) {
|
|
118
|
+
const paths = draftPaths(options.repositoryRoot, options.comparison, options.storageScope);
|
|
119
|
+
const fileSystem = options.fileSystem ?? { readFile: async (path) => fs.readFile(path) };
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
paths,
|
|
122
|
+
async load() {
|
|
123
|
+
try {
|
|
124
|
+
return classifyDraft(await fileSystem.readFile(paths.canonicalPath), paths.relativePath, options.comparison);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (isMissing(error)) {
|
|
128
|
+
return Object.freeze({ kind: 'missing', path: paths.relativePath });
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { CURRENT_DRAFT_SCHEMA_VERSION, DraftMutationSchema, ReviewDraftV1Schema, RevisionSchema, } from '../contracts/draft.js';
|
|
5
|
+
import { applyDraftMutation } from '../draft/mutate-draft.js';
|
|
6
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
7
|
+
import { createDraftLoader } from './draft-loader.js';
|
|
8
|
+
export class DraftStoreError extends Error {
|
|
9
|
+
}
|
|
10
|
+
const queues = new Map();
|
|
11
|
+
const nodeFileSystem = {
|
|
12
|
+
readFile: async (path) => fs.readFile(path),
|
|
13
|
+
mkdir: async (path) => {
|
|
14
|
+
await fs.mkdir(path, { recursive: true });
|
|
15
|
+
},
|
|
16
|
+
open: async (path, flags, mode) => {
|
|
17
|
+
const handle = await fs.open(path, flags, mode);
|
|
18
|
+
return {
|
|
19
|
+
writeFile: async (bytes) => handle.writeFile(bytes),
|
|
20
|
+
sync: async () => handle.sync(),
|
|
21
|
+
close: async () => handle.close(),
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
rename: async (from, to) => fs.rename(from, to),
|
|
25
|
+
unlink: async (path) => fs.unlink(path),
|
|
26
|
+
syncDirectory: async (path) => {
|
|
27
|
+
const handle = await fs.open(path, 'r');
|
|
28
|
+
try {
|
|
29
|
+
await handle.sync();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
function hasCode(error, code) {
|
|
37
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
|
38
|
+
}
|
|
39
|
+
function isUnsupportedDirectorySync(error) {
|
|
40
|
+
return hasCode(error, 'EINVAL') || hasCode(error, 'ENOTSUP') || hasCode(error, 'EISDIR');
|
|
41
|
+
}
|
|
42
|
+
function initialDraft(comparison) {
|
|
43
|
+
return ReviewDraftV1Schema.parse({
|
|
44
|
+
schemaVersion: CURRENT_DRAFT_SCHEMA_VERSION,
|
|
45
|
+
comparison,
|
|
46
|
+
revision: 0,
|
|
47
|
+
summary: '',
|
|
48
|
+
comments: [],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function runSerialized(key, operation) {
|
|
52
|
+
const prior = queues.get(key) ?? Promise.resolve();
|
|
53
|
+
const current = prior.catch(() => undefined).then(operation);
|
|
54
|
+
queues.set(key, current);
|
|
55
|
+
try {
|
|
56
|
+
return await current;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (queues.get(key) === current) {
|
|
60
|
+
queues.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function createDraftStore(options) {
|
|
65
|
+
const fileSystem = options.fileSystem ?? nodeFileSystem;
|
|
66
|
+
const loader = createDraftLoader({
|
|
67
|
+
repositoryRoot: options.repositoryRoot,
|
|
68
|
+
comparison: options.comparison,
|
|
69
|
+
...(options.storageScope === undefined ? {} : { storageScope: options.storageScope }),
|
|
70
|
+
fileSystem,
|
|
71
|
+
});
|
|
72
|
+
const { canonicalPath, directory, key, relativePath } = loader.paths;
|
|
73
|
+
const queueKey = `${options.repositoryRoot}\u0000${key}`;
|
|
74
|
+
const commit = async (draft) => {
|
|
75
|
+
const bytes = Buffer.from(`${JSON.stringify(ReviewDraftV1Schema.parse(draft))}\n`, 'utf8');
|
|
76
|
+
const temporaryPath = join(directory, `.${key}.${randomUUID()}.tmp`);
|
|
77
|
+
let handle;
|
|
78
|
+
let renamed = false;
|
|
79
|
+
try {
|
|
80
|
+
await fileSystem.mkdir(directory);
|
|
81
|
+
handle = await fileSystem.open(temporaryPath, 'wx', 0o600);
|
|
82
|
+
await handle.writeFile(bytes);
|
|
83
|
+
await handle.sync();
|
|
84
|
+
await handle.close();
|
|
85
|
+
handle = undefined;
|
|
86
|
+
await fileSystem.rename(temporaryPath, canonicalPath);
|
|
87
|
+
renamed = true;
|
|
88
|
+
try {
|
|
89
|
+
await fileSystem.syncDirectory(dirname(canonicalPath));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (!isUnsupportedDirectorySync(error)) {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
if (handle !== undefined) {
|
|
99
|
+
try {
|
|
100
|
+
await handle.close();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Canonical bytes are untouched until rename succeeds.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!renamed) {
|
|
107
|
+
try {
|
|
108
|
+
await fileSystem.unlink(temporaryPath);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// A cleanup failure cannot justify touching canonical bytes.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw new DraftStoreError('Draft persistence failed.');
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const backupRaw = async (raw, expectedFingerprint) => {
|
|
118
|
+
await fileSystem.mkdir(directory);
|
|
119
|
+
for (let suffix = 0;; suffix += 1) {
|
|
120
|
+
const name = suffix === 0
|
|
121
|
+
? `${key}.corrupt.${expectedFingerprint}.bak`
|
|
122
|
+
: `${key}.corrupt.${expectedFingerprint}.${suffix}.bak`;
|
|
123
|
+
const absolutePath = join(directory, name);
|
|
124
|
+
let handle;
|
|
125
|
+
let created = false;
|
|
126
|
+
try {
|
|
127
|
+
handle = await fileSystem.open(absolutePath, 'wx', 0o600);
|
|
128
|
+
created = true;
|
|
129
|
+
await handle.writeFile(raw);
|
|
130
|
+
await handle.sync();
|
|
131
|
+
await handle.close();
|
|
132
|
+
handle = undefined;
|
|
133
|
+
try {
|
|
134
|
+
await fileSystem.syncDirectory(directory);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!isUnsupportedDirectorySync(error)) {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const verified = await fileSystem.readFile(absolutePath);
|
|
142
|
+
if (verified.length !== raw.length || !verified.equals(raw)) {
|
|
143
|
+
throw new DraftStoreError('Draft backup verification failed.');
|
|
144
|
+
}
|
|
145
|
+
return `${relativePath.slice(0, -'.json'.length)}.corrupt.${expectedFingerprint}${suffix === 0 ? '' : `.${suffix}`}.bak`;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (handle !== undefined) {
|
|
149
|
+
try {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// The exclusively-created candidate must not be treated as a backup after an incomplete close.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (hasCode(error, 'EEXIST')) {
|
|
157
|
+
try {
|
|
158
|
+
const existing = await fileSystem.readFile(absolutePath);
|
|
159
|
+
if (existing.length === raw.length && existing.equals(raw)) {
|
|
160
|
+
return `${relativePath.slice(0, -'.json'.length)}.corrupt.${expectedFingerprint}${suffix === 0 ? '' : `.${suffix}`}.bak`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Never overwrite an existing backup whose bytes cannot be proven identical.
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (created) {
|
|
169
|
+
try {
|
|
170
|
+
await fileSystem.unlink(absolutePath);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Failed candidate cleanup does not affect the canonical draft.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
canonicalPath,
|
|
182
|
+
async load() {
|
|
183
|
+
const load = await loader.load();
|
|
184
|
+
if (load.kind === 'missing') {
|
|
185
|
+
return initialDraft(options.comparison);
|
|
186
|
+
}
|
|
187
|
+
if (load.kind === 'current') {
|
|
188
|
+
return load.draft;
|
|
189
|
+
}
|
|
190
|
+
throw new DraftStoreError('Existing draft cannot be used for this comparison.');
|
|
191
|
+
},
|
|
192
|
+
loadState: async () => loader.load(),
|
|
193
|
+
async settle(expectedRevision, operation) {
|
|
194
|
+
return runSerialized(queueKey, async () => {
|
|
195
|
+
const expected = RevisionSchema.parse(expectedRevision);
|
|
196
|
+
let initial;
|
|
197
|
+
try {
|
|
198
|
+
initial = await loader.load();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
202
|
+
}
|
|
203
|
+
if (initial.kind === 'malformed' || initial.kind === 'schemaInvalid' || initial.kind === 'newerUnsupported') {
|
|
204
|
+
return Object.freeze({ kind: 'readOnly', load: initial });
|
|
205
|
+
}
|
|
206
|
+
const draft = initial.kind === 'missing' ? initialDraft(options.comparison) : initial.draft;
|
|
207
|
+
if (draft.revision !== expected) {
|
|
208
|
+
return Object.freeze({ kind: 'revisionConflict', expectedRevision: expected, actualRevision: draft.revision });
|
|
209
|
+
}
|
|
210
|
+
let prepared;
|
|
211
|
+
try {
|
|
212
|
+
prepared = await operation.prepare(structuredClone(draft));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
216
|
+
}
|
|
217
|
+
let final;
|
|
218
|
+
try {
|
|
219
|
+
final = await loader.load();
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
223
|
+
}
|
|
224
|
+
if (final.kind !== initial.kind
|
|
225
|
+
|| (final.kind === 'current' && initial.kind === 'current' && !final.raw.equals(initial.raw))) {
|
|
226
|
+
return Object.freeze({ kind: 'draftChanged', actualRevision: final.kind === 'current' ? final.draft.revision : 0 });
|
|
227
|
+
}
|
|
228
|
+
if (final.kind !== 'missing' && final.kind !== 'current') {
|
|
229
|
+
return Object.freeze({ kind: 'readOnly', load: final });
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return Object.freeze({
|
|
233
|
+
kind: 'accepted',
|
|
234
|
+
value: await operation.finalize({ draft: structuredClone(draft), prepared }),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
async mutate(input) {
|
|
243
|
+
return runSerialized(queueKey, async () => {
|
|
244
|
+
const expectedRevision = RevisionSchema.parse(input.expectedRevision);
|
|
245
|
+
const load = await loader.load();
|
|
246
|
+
if (load.kind === 'malformed' || load.kind === 'schemaInvalid' || load.kind === 'newerUnsupported') {
|
|
247
|
+
return Object.freeze({ kind: 'readOnly', load });
|
|
248
|
+
}
|
|
249
|
+
const current = load.kind === 'missing' ? initialDraft(options.comparison) : load.draft;
|
|
250
|
+
if (expectedRevision !== current.revision) {
|
|
251
|
+
return Object.freeze({
|
|
252
|
+
kind: 'revisionConflict',
|
|
253
|
+
expectedRevision,
|
|
254
|
+
actualRevision: current.revision,
|
|
255
|
+
latest: current,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const mutation = DraftMutationSchema.parse(input.mutation.type === 'addComment'
|
|
259
|
+
? { ...input.mutation, commentId: `comment_${randomUUID()}` }
|
|
260
|
+
: input.mutation);
|
|
261
|
+
const applied = applyDraftMutation(current, mutation, new Date().toISOString());
|
|
262
|
+
if (applied.kind !== 'applied') {
|
|
263
|
+
return applied;
|
|
264
|
+
}
|
|
265
|
+
const next = ReviewDraftV1Schema.parse({ ...applied.draft, revision: current.revision + 1 });
|
|
266
|
+
try {
|
|
267
|
+
await commit(next);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
271
|
+
}
|
|
272
|
+
return Object.freeze({ kind: 'accepted', draft: next });
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
async recover(input) {
|
|
276
|
+
return runSerialized(queueKey, async () => {
|
|
277
|
+
let load;
|
|
278
|
+
try {
|
|
279
|
+
load = await loader.load();
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
283
|
+
}
|
|
284
|
+
if (load.kind !== 'malformed' && load.kind !== 'schemaInvalid') {
|
|
285
|
+
return Object.freeze({ kind: 'recoveryUnavailable', load });
|
|
286
|
+
}
|
|
287
|
+
if (load.fingerprint !== input.expectedFingerprint) {
|
|
288
|
+
return Object.freeze({ kind: 'fingerprintChanged' });
|
|
289
|
+
}
|
|
290
|
+
let backupPath;
|
|
291
|
+
try {
|
|
292
|
+
backupPath = await backupRaw(load.raw, load.fingerprint);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
296
|
+
}
|
|
297
|
+
const draft = initialDraft(options.comparison);
|
|
298
|
+
try {
|
|
299
|
+
await commit(draft);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// A post-rename directory sync can report failure after the replacement is visible.
|
|
303
|
+
// Do not claim a failed recovery while leaving callers to retry against new canonical bytes.
|
|
304
|
+
let visible;
|
|
305
|
+
try {
|
|
306
|
+
visible = await loader.load();
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
310
|
+
}
|
|
311
|
+
if (visible.kind !== 'current') {
|
|
312
|
+
return Object.freeze({ kind: 'persistenceFailure' });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return Object.freeze({ kind: 'recovered', backupPath, draft });
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
export async function findUncommittedDraftCandidate(repositoryRoot, sourceKind, baseCommitOid) {
|
|
321
|
+
const directory = join(repositoryRoot, RENAME_MAP.repoStateDir.replace(/\/$/, ''), 'drafts');
|
|
322
|
+
let entries;
|
|
323
|
+
try {
|
|
324
|
+
entries = await fs.readdir(directory);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
if (!entry.endsWith('.json'))
|
|
331
|
+
continue;
|
|
332
|
+
const candidatePath = join(directory, entry);
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(await fs.readFile(candidatePath, 'utf8'));
|
|
335
|
+
if (typeof parsed !== 'object' || parsed === null || !('comparison' in parsed))
|
|
336
|
+
continue;
|
|
337
|
+
const comparison = parsed.comparison;
|
|
338
|
+
if (typeof comparison !== 'object' || comparison === null)
|
|
339
|
+
continue;
|
|
340
|
+
if (!('kind' in comparison) || comparison.kind !== 'uncommitted')
|
|
341
|
+
continue;
|
|
342
|
+
if (!('sourceKind' in comparison) || comparison.sourceKind !== sourceKind)
|
|
343
|
+
continue;
|
|
344
|
+
const storedBaseCommitOid = 'baseCommitOid' in comparison ? comparison.baseCommitOid : undefined;
|
|
345
|
+
if (storedBaseCommitOid !== baseCommitOid)
|
|
346
|
+
continue;
|
|
347
|
+
if (!('reviewKey' in comparison) || typeof comparison.reviewKey !== 'string')
|
|
348
|
+
continue;
|
|
349
|
+
return Object.freeze({ reviewKey: comparison.reviewKey, path: candidatePath });
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
export async function loadUncommittedDraftCandidateComments(path) {
|
|
358
|
+
let raw;
|
|
359
|
+
try {
|
|
360
|
+
raw = await fs.readFile(path, 'utf8');
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
let parsed;
|
|
366
|
+
try {
|
|
367
|
+
parsed = JSON.parse(raw);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
const result = ReviewDraftV1Schema.safeParse(parsed);
|
|
373
|
+
if (!result.success)
|
|
374
|
+
return undefined;
|
|
375
|
+
return result.data.comments.map((comment) => ({
|
|
376
|
+
body: comment.body,
|
|
377
|
+
state: comment.state,
|
|
378
|
+
anchor: comment.anchor,
|
|
379
|
+
}));
|
|
380
|
+
}
|