@remixhq/core 0.1.12 → 0.1.13
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/dist/api.d.ts +18 -1
- package/dist/api.js +1 -1
- package/dist/binding.js +2 -2
- package/dist/chunk-CUUXZSKW.js +611 -0
- package/dist/chunk-E6AYE22H.js +343 -0
- package/dist/chunk-OZRXDDEL.js +46 -0
- package/dist/chunk-R7FVSCQW.js +415 -0
- package/dist/chunk-RKMMEML5.js +46 -0
- package/dist/chunk-UIGKSCTD.js +406 -0
- package/dist/chunk-UWIVJRTI.js +343 -0
- package/dist/chunk-VA6WXRWB.js +636 -0
- package/dist/chunk-WT6VRLXU.js +636 -0
- package/dist/chunk-YCFLOHJV.js +343 -0
- package/dist/collab.d.ts +138 -133
- package/dist/collab.js +2664 -1426
- package/dist/index.js +1 -1
- package/dist/repo.js +1 -1
- package/package.json +1 -1
package/dist/collab.js
CHANGED
|
@@ -5,43 +5,32 @@ import {
|
|
|
5
5
|
reserveAvailableDirPath,
|
|
6
6
|
reserveDirectory,
|
|
7
7
|
writeCollabBinding,
|
|
8
|
-
writeCollabBindingSnapshot
|
|
9
|
-
|
|
8
|
+
writeCollabBindingSnapshot,
|
|
9
|
+
writeJsonAtomic
|
|
10
|
+
} from "./chunk-YCFLOHJV.js";
|
|
10
11
|
import "./chunk-HZNEDSRS.js";
|
|
11
12
|
import {
|
|
13
|
+
applyUnifiedDiffToWorktree,
|
|
12
14
|
assertRepoSnapshotUnchanged,
|
|
13
15
|
buildRepoFingerprint,
|
|
14
16
|
captureRepoSnapshot,
|
|
15
17
|
checkoutLocalBranch,
|
|
16
18
|
cloneGitBundleToDirectory,
|
|
17
|
-
createBackupBranch,
|
|
18
19
|
createGitBundle,
|
|
19
|
-
discardCapturedUntrackedChanges,
|
|
20
|
-
discardTrackedChanges,
|
|
21
20
|
ensureCleanWorktree,
|
|
22
|
-
ensureCommitExists,
|
|
23
21
|
ensureGitInfoExcludeEntries,
|
|
24
|
-
fastForwardToCommit,
|
|
25
22
|
findGitRoot,
|
|
26
23
|
getCurrentBranch,
|
|
27
24
|
getDefaultBranch,
|
|
28
25
|
getGitCommonDir,
|
|
29
26
|
getHeadCommitHash,
|
|
30
27
|
getRemoteOriginUrl,
|
|
31
|
-
getWorkspaceDiff,
|
|
32
|
-
getWorkspaceSnapshot,
|
|
33
28
|
getWorktreeStatus,
|
|
34
|
-
hardResetToCommit,
|
|
35
|
-
importGitBundle,
|
|
36
|
-
listUntrackedFiles,
|
|
37
29
|
normalizeGitRemote,
|
|
38
|
-
preserveWorkspaceChanges,
|
|
39
|
-
reapplyPreservedWorkspaceChanges,
|
|
40
30
|
requireCurrentBranch,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} from "./chunk-RREREIGW.js";
|
|
31
|
+
setRemoteOriginUrl,
|
|
32
|
+
summarizeUnifiedDiff
|
|
33
|
+
} from "./chunk-WT6VRLXU.js";
|
|
45
34
|
import {
|
|
46
35
|
REMIX_ERROR_CODES
|
|
47
36
|
} from "./chunk-GC2MOT3U.js";
|
|
@@ -49,10 +38,6 @@ import {
|
|
|
49
38
|
RemixError
|
|
50
39
|
} from "./chunk-YZ34ICNN.js";
|
|
51
40
|
|
|
52
|
-
// src/application/collab/collabAdd.ts
|
|
53
|
-
import fs3 from "fs/promises";
|
|
54
|
-
import path3 from "path";
|
|
55
|
-
|
|
56
41
|
// src/application/collab/branchPolicy.ts
|
|
57
42
|
function describeBranch(value) {
|
|
58
43
|
const normalized = String(value ?? "").trim();
|
|
@@ -86,8 +71,625 @@ function assertBoundBranchMatch(params) {
|
|
|
86
71
|
});
|
|
87
72
|
}
|
|
88
73
|
|
|
89
|
-
// src/
|
|
74
|
+
// src/infrastructure/collab/localBaselineStore.ts
|
|
75
|
+
import fs from "fs/promises";
|
|
76
|
+
import path2 from "path";
|
|
77
|
+
|
|
78
|
+
// src/infrastructure/collab/statePaths.ts
|
|
90
79
|
import { createHash } from "crypto";
|
|
80
|
+
import os from "os";
|
|
81
|
+
import path from "path";
|
|
82
|
+
function sha256Hex(value) {
|
|
83
|
+
return createHash("sha256").update(value).digest("hex");
|
|
84
|
+
}
|
|
85
|
+
function getCollabStateRoot() {
|
|
86
|
+
const configured = process.env.REMIX_COLLAB_STATE_ROOT?.trim();
|
|
87
|
+
return configured || path.join(os.homedir(), ".remix", "collab-state");
|
|
88
|
+
}
|
|
89
|
+
function buildLaneStateKey(params) {
|
|
90
|
+
const fingerprint = params.repoFingerprint?.trim();
|
|
91
|
+
const laneId = params.laneId?.trim();
|
|
92
|
+
const repoRoot = params.repoRoot?.trim();
|
|
93
|
+
const stableSource = repoRoot || "unknown-repo-root";
|
|
94
|
+
const fingerprintSource = fingerprint || "unknown-repo-fingerprint";
|
|
95
|
+
const laneSource = laneId || "unknown-lane";
|
|
96
|
+
return sha256Hex(`${stableSource}::${fingerprintSource}::${laneSource}`);
|
|
97
|
+
}
|
|
98
|
+
function getSnapshotsRoot() {
|
|
99
|
+
return path.join(getCollabStateRoot(), "snapshots");
|
|
100
|
+
}
|
|
101
|
+
function getSnapshotRecordsRoot() {
|
|
102
|
+
return path.join(getSnapshotsRoot(), "records");
|
|
103
|
+
}
|
|
104
|
+
function getSnapshotBlobsRoot() {
|
|
105
|
+
return path.join(getSnapshotsRoot(), "blobs");
|
|
106
|
+
}
|
|
107
|
+
function getBaselinesRoot() {
|
|
108
|
+
return path.join(getCollabStateRoot(), "baselines");
|
|
109
|
+
}
|
|
110
|
+
function getFinalizeQueueRoot() {
|
|
111
|
+
return path.join(getCollabStateRoot(), "finalize-queue");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/infrastructure/collab/localBaselineStore.ts
|
|
115
|
+
function getBaselinePath(params) {
|
|
116
|
+
return path2.join(getBaselinesRoot(), `${buildLaneStateKey(params)}.json`);
|
|
117
|
+
}
|
|
118
|
+
async function readLocalBaseline(params) {
|
|
119
|
+
try {
|
|
120
|
+
const raw = await fs.readFile(getBaselinePath(params), "utf8");
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
123
|
+
if (parsed.schemaVersion !== 1 || typeof parsed.key !== "string" || typeof parsed.repoRoot !== "string") {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
schemaVersion: 1,
|
|
128
|
+
key: parsed.key,
|
|
129
|
+
repoRoot: parsed.repoRoot,
|
|
130
|
+
repoFingerprint: parsed.repoFingerprint ?? null,
|
|
131
|
+
laneId: parsed.laneId ?? null,
|
|
132
|
+
currentAppId: String(parsed.currentAppId ?? ""),
|
|
133
|
+
branchName: parsed.branchName ?? null,
|
|
134
|
+
lastSnapshotId: parsed.lastSnapshotId ?? null,
|
|
135
|
+
lastSnapshotHash: parsed.lastSnapshotHash ?? null,
|
|
136
|
+
lastServerHeadHash: parsed.lastServerHeadHash ?? null,
|
|
137
|
+
lastSeenLocalCommitHash: parsed.lastSeenLocalCommitHash ?? null,
|
|
138
|
+
updatedAt: String(parsed.updatedAt ?? "")
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function writeLocalBaseline(baseline) {
|
|
145
|
+
const key = buildLaneStateKey(baseline);
|
|
146
|
+
const normalized = {
|
|
147
|
+
schemaVersion: 1,
|
|
148
|
+
key,
|
|
149
|
+
repoRoot: baseline.repoRoot,
|
|
150
|
+
repoFingerprint: baseline.repoFingerprint ?? null,
|
|
151
|
+
laneId: baseline.laneId ?? null,
|
|
152
|
+
currentAppId: baseline.currentAppId,
|
|
153
|
+
branchName: baseline.branchName ?? null,
|
|
154
|
+
lastSnapshotId: baseline.lastSnapshotId ?? null,
|
|
155
|
+
lastSnapshotHash: baseline.lastSnapshotHash ?? null,
|
|
156
|
+
lastServerHeadHash: baseline.lastServerHeadHash ?? null,
|
|
157
|
+
lastSeenLocalCommitHash: baseline.lastSeenLocalCommitHash ?? null,
|
|
158
|
+
updatedAt: baseline.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
159
|
+
};
|
|
160
|
+
await writeJsonAtomic(getBaselinePath(baseline), normalized);
|
|
161
|
+
return normalized;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/infrastructure/collab/localSnapshotStore.ts
|
|
165
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
166
|
+
import fs2 from "fs/promises";
|
|
167
|
+
import os2 from "os";
|
|
168
|
+
import path3 from "path";
|
|
169
|
+
import { execa } from "execa";
|
|
170
|
+
function sha256Hex2(value) {
|
|
171
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
172
|
+
}
|
|
173
|
+
function getSnapshotRecordPath(snapshotId) {
|
|
174
|
+
return path3.join(getSnapshotRecordsRoot(), `${snapshotId}.json`);
|
|
175
|
+
}
|
|
176
|
+
function getBlobPath(blobHash) {
|
|
177
|
+
return path3.join(getSnapshotBlobsRoot(), blobHash.slice(0, 2), blobHash);
|
|
178
|
+
}
|
|
179
|
+
async function runGitZ(args, cwd) {
|
|
180
|
+
const res = await execa("git", args, {
|
|
181
|
+
cwd,
|
|
182
|
+
stderr: "ignore",
|
|
183
|
+
stripFinalNewline: false
|
|
184
|
+
});
|
|
185
|
+
return String(res.stdout || "");
|
|
186
|
+
}
|
|
187
|
+
async function listWorkspaceFiles(repoRoot) {
|
|
188
|
+
const raw = await runGitZ(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--deduplicate"], repoRoot);
|
|
189
|
+
const seen = /* @__PURE__ */ new Set();
|
|
190
|
+
const result = [];
|
|
191
|
+
for (const entry of raw.split("\0")) {
|
|
192
|
+
const relativePath = entry.trim();
|
|
193
|
+
if (!relativePath || seen.has(relativePath)) continue;
|
|
194
|
+
const absolutePath = path3.join(repoRoot, relativePath);
|
|
195
|
+
try {
|
|
196
|
+
const stat = await fs2.lstat(absolutePath);
|
|
197
|
+
if (stat.isFile() || stat.isSymbolicLink()) {
|
|
198
|
+
seen.add(relativePath);
|
|
199
|
+
result.push(relativePath);
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return result.sort((a, b) => a.localeCompare(b));
|
|
205
|
+
}
|
|
206
|
+
async function persistBlob(blobHash, content) {
|
|
207
|
+
const blobPath = getBlobPath(blobHash);
|
|
208
|
+
try {
|
|
209
|
+
await fs2.access(blobPath);
|
|
210
|
+
} catch {
|
|
211
|
+
await fs2.mkdir(path3.dirname(blobPath), { recursive: true });
|
|
212
|
+
if (typeof content === "string") {
|
|
213
|
+
await fs2.writeFile(blobPath, content, "utf8");
|
|
214
|
+
} else {
|
|
215
|
+
await fs2.writeFile(blobPath, content);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function buildSnapshotHash(files) {
|
|
220
|
+
const manifest = files.map((file) => `${file.path} ${file.mode} ${file.blobHash} ${file.size}`).join("\n");
|
|
221
|
+
return sha256Hex2(manifest);
|
|
222
|
+
}
|
|
223
|
+
async function inspectLocalSnapshot(params) {
|
|
224
|
+
const repoRoot = params.repoRoot;
|
|
225
|
+
const files = await listWorkspaceFiles(repoRoot);
|
|
226
|
+
const manifest = [];
|
|
227
|
+
for (const relativePath of files) {
|
|
228
|
+
const absolutePath = path3.join(repoRoot, relativePath);
|
|
229
|
+
const stat = await fs2.lstat(absolutePath);
|
|
230
|
+
if (stat.isSymbolicLink()) {
|
|
231
|
+
const linkTarget = await fs2.readlink(absolutePath);
|
|
232
|
+
const blobHash2 = sha256Hex2(`symlink:${linkTarget}`);
|
|
233
|
+
if (params.persistBlobs !== false) {
|
|
234
|
+
await persistBlob(blobHash2, linkTarget);
|
|
235
|
+
}
|
|
236
|
+
manifest.push({
|
|
237
|
+
path: relativePath,
|
|
238
|
+
mode: "symlink",
|
|
239
|
+
blobHash: blobHash2,
|
|
240
|
+
size: Buffer.byteLength(linkTarget)
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const content = await fs2.readFile(absolutePath);
|
|
245
|
+
const blobHash = sha256Hex2(content);
|
|
246
|
+
if (params.persistBlobs !== false) {
|
|
247
|
+
await persistBlob(blobHash, content);
|
|
248
|
+
}
|
|
249
|
+
manifest.push({
|
|
250
|
+
path: relativePath,
|
|
251
|
+
mode: stat.mode & 73 ? "executable" : "file",
|
|
252
|
+
blobHash,
|
|
253
|
+
size: stat.size
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const normalizedManifest = manifest.sort((a, b) => a.path.localeCompare(b.path));
|
|
257
|
+
return {
|
|
258
|
+
repoRoot,
|
|
259
|
+
repoFingerprint: params.repoFingerprint ?? null,
|
|
260
|
+
laneId: params.laneId ?? null,
|
|
261
|
+
branchName: params.branchName ?? await getCurrentBranch(repoRoot).catch(() => null),
|
|
262
|
+
localCommitHash: await getHeadCommitHash(repoRoot).catch(() => null),
|
|
263
|
+
snapshotHash: buildSnapshotHash(normalizedManifest),
|
|
264
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
265
|
+
files: normalizedManifest
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function captureLocalSnapshot(params) {
|
|
269
|
+
const inspection = await inspectLocalSnapshot({ ...params, persistBlobs: true });
|
|
270
|
+
const snapshot = {
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
id: randomUUID(),
|
|
273
|
+
...inspection
|
|
274
|
+
};
|
|
275
|
+
await writeJsonAtomic(getSnapshotRecordPath(snapshot.id), snapshot);
|
|
276
|
+
return snapshot;
|
|
277
|
+
}
|
|
278
|
+
async function readLocalSnapshot(snapshotId) {
|
|
279
|
+
if (!snapshotId) return null;
|
|
280
|
+
try {
|
|
281
|
+
const raw = await fs2.readFile(getSnapshotRecordPath(snapshotId), "utf8");
|
|
282
|
+
const parsed = JSON.parse(raw);
|
|
283
|
+
if (!parsed || parsed.schemaVersion !== 1) return null;
|
|
284
|
+
return parsed;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function materializeLocalSnapshot(snapshotId, targetDir) {
|
|
290
|
+
const snapshot = await readLocalSnapshot(snapshotId);
|
|
291
|
+
await fs2.mkdir(targetDir, { recursive: true });
|
|
292
|
+
if (!snapshot) return;
|
|
293
|
+
for (const entry of snapshot.files) {
|
|
294
|
+
const destination = path3.join(targetDir, entry.path);
|
|
295
|
+
await fs2.mkdir(path3.dirname(destination), { recursive: true });
|
|
296
|
+
const blobPath = getBlobPath(entry.blobHash);
|
|
297
|
+
if (entry.mode === "symlink") {
|
|
298
|
+
const linkTarget = await fs2.readFile(blobPath, "utf8");
|
|
299
|
+
await fs2.symlink(linkTarget, destination);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
await fs2.copyFile(blobPath, destination);
|
|
303
|
+
if (entry.mode === "executable") {
|
|
304
|
+
await fs2.chmod(destination, 493);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function pruneEmptyParentDirectories(repoRoot, filePath) {
|
|
309
|
+
let current = path3.dirname(filePath);
|
|
310
|
+
while (current !== repoRoot) {
|
|
311
|
+
const entries = await fs2.readdir(current).catch(() => null);
|
|
312
|
+
if (!entries || entries.length > 0) return;
|
|
313
|
+
await fs2.rmdir(current).catch(() => void 0);
|
|
314
|
+
current = path3.dirname(current);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function restoreLocalSnapshotToWorktree(snapshotId, repoRoot) {
|
|
318
|
+
const snapshot = await readLocalSnapshot(snapshotId);
|
|
319
|
+
await fs2.mkdir(repoRoot, { recursive: true });
|
|
320
|
+
const desiredPaths = new Set(snapshot?.files.map((entry) => entry.path) ?? []);
|
|
321
|
+
const currentPaths = await listWorkspaceFiles(repoRoot);
|
|
322
|
+
for (const relativePath of currentPaths) {
|
|
323
|
+
if (desiredPaths.has(relativePath)) continue;
|
|
324
|
+
const absolutePath = path3.join(repoRoot, relativePath);
|
|
325
|
+
await fs2.rm(absolutePath, { recursive: true, force: true }).catch(() => void 0);
|
|
326
|
+
await pruneEmptyParentDirectories(repoRoot, absolutePath);
|
|
327
|
+
}
|
|
328
|
+
if (!snapshot) return;
|
|
329
|
+
for (const entry of snapshot.files) {
|
|
330
|
+
const destination = path3.join(repoRoot, entry.path);
|
|
331
|
+
await fs2.mkdir(path3.dirname(destination), { recursive: true });
|
|
332
|
+
await fs2.rm(destination, { recursive: true, force: true }).catch(() => void 0);
|
|
333
|
+
const blobPath = getBlobPath(entry.blobHash);
|
|
334
|
+
if (entry.mode === "symlink") {
|
|
335
|
+
const linkTarget = await fs2.readFile(blobPath, "utf8");
|
|
336
|
+
await fs2.symlink(linkTarget, destination);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
await fs2.copyFile(blobPath, destination);
|
|
340
|
+
if (entry.mode === "executable") {
|
|
341
|
+
await fs2.chmod(destination, 493);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function clearDirectoryExceptGit(targetDir) {
|
|
346
|
+
const entries = await fs2.readdir(targetDir, { withFileTypes: true });
|
|
347
|
+
for (const entry of entries) {
|
|
348
|
+
if (entry.name === ".git") continue;
|
|
349
|
+
await fs2.rm(path3.join(targetDir, entry.name), { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function diffLocalSnapshots(params) {
|
|
353
|
+
const tempRoot = await fs2.mkdtemp(path3.join(os2.tmpdir(), "remix-snapshot-diff-"));
|
|
354
|
+
const repoDir = path3.join(tempRoot, "repo");
|
|
355
|
+
await fs2.mkdir(repoDir, { recursive: true });
|
|
356
|
+
try {
|
|
357
|
+
await materializeLocalSnapshot(params.baseSnapshotId, repoDir);
|
|
358
|
+
await execa("git", ["init"], { cwd: repoDir, stderr: "ignore" });
|
|
359
|
+
await execa("git", ["add", "-A"], { cwd: repoDir, stderr: "ignore" });
|
|
360
|
+
await execa(
|
|
361
|
+
"git",
|
|
362
|
+
["-c", "user.name=Remix", "-c", "user.email=remix@local", "commit", "--allow-empty", "-m", "baseline snapshot"],
|
|
363
|
+
{ cwd: repoDir, stderr: "ignore" }
|
|
364
|
+
);
|
|
365
|
+
await clearDirectoryExceptGit(repoDir);
|
|
366
|
+
await materializeLocalSnapshot(params.targetSnapshotId, repoDir);
|
|
367
|
+
await execa("git", ["add", "-A"], { cwd: repoDir, stderr: "ignore" });
|
|
368
|
+
const diffRes = await execa("git", ["diff", "--binary", "--no-ext-diff", "--cached", "HEAD"], {
|
|
369
|
+
cwd: repoDir,
|
|
370
|
+
reject: false,
|
|
371
|
+
stderr: "ignore",
|
|
372
|
+
stripFinalNewline: false
|
|
373
|
+
});
|
|
374
|
+
const pathsRes = await execa("git", ["diff", "--name-only", "--cached", "HEAD", "-z"], {
|
|
375
|
+
cwd: repoDir,
|
|
376
|
+
reject: false,
|
|
377
|
+
stderr: "ignore",
|
|
378
|
+
stripFinalNewline: false
|
|
379
|
+
});
|
|
380
|
+
const diff = String(diffRes.stdout || "");
|
|
381
|
+
const changedPaths = String(pathsRes.stdout || "").split("\0").map((value) => value.trim()).filter(Boolean);
|
|
382
|
+
return {
|
|
383
|
+
baseSnapshotId: params.baseSnapshotId,
|
|
384
|
+
targetSnapshotId: params.targetSnapshotId,
|
|
385
|
+
diff,
|
|
386
|
+
diffSha256: diff ? sha256Hex2(diff) : null,
|
|
387
|
+
changedPaths,
|
|
388
|
+
stats: summarizeUnifiedDiff(diff)
|
|
389
|
+
};
|
|
390
|
+
} finally {
|
|
391
|
+
await fs2.rm(tempRoot, { recursive: true, force: true });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/infrastructure/collab/pendingFinalizeQueue.ts
|
|
396
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
397
|
+
import fs3 from "fs/promises";
|
|
398
|
+
import path4 from "path";
|
|
399
|
+
var FINALIZE_JOB_LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
400
|
+
var TERMINAL_FINALIZE_JOB_RETENTION_MS = 24 * 60 * 60 * 1e3;
|
|
401
|
+
function getJobPath(id) {
|
|
402
|
+
return path4.join(getFinalizeQueueRoot(), `${id}.json`);
|
|
403
|
+
}
|
|
404
|
+
function getJobLockPath(id) {
|
|
405
|
+
return path4.join(getFinalizeQueueRoot(), `${id}.lock`);
|
|
406
|
+
}
|
|
407
|
+
function isPastDue(isoTimestamp) {
|
|
408
|
+
if (!isoTimestamp) return true;
|
|
409
|
+
const parsed = Date.parse(isoTimestamp);
|
|
410
|
+
return Number.isFinite(parsed) && parsed <= Date.now();
|
|
411
|
+
}
|
|
412
|
+
function isStaleAttempt(job) {
|
|
413
|
+
if (job.status !== "processing") return false;
|
|
414
|
+
if (!job.lastAttemptAt) return true;
|
|
415
|
+
const parsed = Date.parse(job.lastAttemptAt);
|
|
416
|
+
if (!Number.isFinite(parsed)) return true;
|
|
417
|
+
return Date.now() - parsed >= FINALIZE_JOB_LOCK_STALE_MS;
|
|
418
|
+
}
|
|
419
|
+
function readMetadataDisposition(job) {
|
|
420
|
+
const value = job.metadata.failureDisposition;
|
|
421
|
+
return value === "retryable" || value === "terminal" ? value : null;
|
|
422
|
+
}
|
|
423
|
+
function isTerminalFailure(job) {
|
|
424
|
+
return job.status === "failed" && readMetadataDisposition(job) === "terminal";
|
|
425
|
+
}
|
|
426
|
+
function isTerminalFailureExpired(job) {
|
|
427
|
+
if (!isTerminalFailure(job)) return false;
|
|
428
|
+
const updatedAtMs = Date.parse(job.updatedAt);
|
|
429
|
+
if (!Number.isFinite(updatedAtMs)) return false;
|
|
430
|
+
return Date.now() - updatedAtMs >= TERMINAL_FINALIZE_JOB_RETENTION_MS;
|
|
431
|
+
}
|
|
432
|
+
function matchesJobScope(job, scope) {
|
|
433
|
+
if (job.repoRoot !== scope.repoRoot) return false;
|
|
434
|
+
if (scope.currentAppId && job.currentAppId !== scope.currentAppId) return false;
|
|
435
|
+
if (scope.laneId && job.laneId !== scope.laneId) return false;
|
|
436
|
+
if (scope.repoFingerprint && job.repoFingerprint && job.repoFingerprint !== scope.repoFingerprint) return false;
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
function createEmptyPendingFinalizeQueueSummary() {
|
|
440
|
+
return {
|
|
441
|
+
state: "idle",
|
|
442
|
+
activeJobCount: 0,
|
|
443
|
+
queuedJobCount: 0,
|
|
444
|
+
processingJobCount: 0,
|
|
445
|
+
retryScheduledJobCount: 0,
|
|
446
|
+
failedJobCount: 0,
|
|
447
|
+
oldestCapturedAt: null,
|
|
448
|
+
newestCapturedAt: null,
|
|
449
|
+
nextRetryAt: null,
|
|
450
|
+
latestError: null
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function acquireJobLock(jobId) {
|
|
454
|
+
const lockPath = getJobLockPath(jobId);
|
|
455
|
+
try {
|
|
456
|
+
await fs3.mkdir(lockPath);
|
|
457
|
+
return true;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (error?.code !== "EEXIST") {
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const stat = await fs3.stat(lockPath);
|
|
465
|
+
if (Date.now() - stat.mtimeMs < FINALIZE_JOB_LOCK_STALE_MS) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
await fs3.rm(lockPath, { recursive: true, force: true });
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (error?.code !== "ENOENT") {
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
await fs3.mkdir(lockPath);
|
|
476
|
+
return true;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (error?.code === "EEXIST") {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function normalizeJob(input) {
|
|
485
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
486
|
+
return {
|
|
487
|
+
schemaVersion: 1,
|
|
488
|
+
id: input.id ?? randomUUID2(),
|
|
489
|
+
status: input.status,
|
|
490
|
+
repoRoot: input.repoRoot,
|
|
491
|
+
repoFingerprint: input.repoFingerprint ?? null,
|
|
492
|
+
currentAppId: input.currentAppId,
|
|
493
|
+
laneId: input.laneId ?? null,
|
|
494
|
+
threadId: input.threadId ?? null,
|
|
495
|
+
branchName: input.branchName ?? null,
|
|
496
|
+
prompt: input.prompt,
|
|
497
|
+
assistantResponse: input.assistantResponse,
|
|
498
|
+
baselineSnapshotId: input.baselineSnapshotId ?? null,
|
|
499
|
+
baselineServerHeadHash: input.baselineServerHeadHash ?? null,
|
|
500
|
+
currentSnapshotId: input.currentSnapshotId,
|
|
501
|
+
capturedAt: input.capturedAt ?? now,
|
|
502
|
+
updatedAt: input.updatedAt ?? now,
|
|
503
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
504
|
+
error: input.error ?? null,
|
|
505
|
+
retryCount: Number.isFinite(input.retryCount) ? Math.max(0, Number(input.retryCount)) : 0,
|
|
506
|
+
lastAttemptAt: input.lastAttemptAt ?? null,
|
|
507
|
+
nextRetryAt: input.nextRetryAt ?? null,
|
|
508
|
+
metadata: input.metadata ?? {}
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
async function enqueuePendingFinalizeJob(input) {
|
|
512
|
+
const job = normalizeJob(input);
|
|
513
|
+
await writeJsonAtomic(getJobPath(job.id), job);
|
|
514
|
+
return job;
|
|
515
|
+
}
|
|
516
|
+
async function readPendingFinalizeJob(jobId) {
|
|
517
|
+
try {
|
|
518
|
+
const raw = await fs3.readFile(getJobPath(jobId), "utf8");
|
|
519
|
+
const parsed = JSON.parse(raw);
|
|
520
|
+
if (!parsed || parsed.schemaVersion !== 1 || typeof parsed.id !== "string") return null;
|
|
521
|
+
return normalizeJob({
|
|
522
|
+
id: parsed.id,
|
|
523
|
+
status: parsed.status ?? "queued",
|
|
524
|
+
repoRoot: String(parsed.repoRoot ?? ""),
|
|
525
|
+
repoFingerprint: parsed.repoFingerprint ?? null,
|
|
526
|
+
currentAppId: String(parsed.currentAppId ?? ""),
|
|
527
|
+
laneId: parsed.laneId ?? null,
|
|
528
|
+
threadId: parsed.threadId ?? null,
|
|
529
|
+
branchName: parsed.branchName ?? null,
|
|
530
|
+
prompt: String(parsed.prompt ?? ""),
|
|
531
|
+
assistantResponse: String(parsed.assistantResponse ?? ""),
|
|
532
|
+
baselineSnapshotId: parsed.baselineSnapshotId ?? null,
|
|
533
|
+
baselineServerHeadHash: parsed.baselineServerHeadHash ?? null,
|
|
534
|
+
currentSnapshotId: String(parsed.currentSnapshotId ?? ""),
|
|
535
|
+
capturedAt: parsed.capturedAt,
|
|
536
|
+
updatedAt: parsed.updatedAt,
|
|
537
|
+
idempotencyKey: parsed.idempotencyKey ?? null,
|
|
538
|
+
error: parsed.error ?? null,
|
|
539
|
+
retryCount: parsed.retryCount ?? 0,
|
|
540
|
+
lastAttemptAt: parsed.lastAttemptAt ?? null,
|
|
541
|
+
nextRetryAt: parsed.nextRetryAt ?? null,
|
|
542
|
+
metadata: parsed.metadata ?? {}
|
|
543
|
+
});
|
|
544
|
+
} catch {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function listPendingFinalizeJobs() {
|
|
549
|
+
try {
|
|
550
|
+
const entries = await fs3.readdir(getFinalizeQueueRoot(), { withFileTypes: true });
|
|
551
|
+
const jobs = await Promise.all(
|
|
552
|
+
entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => readPendingFinalizeJob(entry.name.replace(/\.json$/, "")))
|
|
553
|
+
);
|
|
554
|
+
return jobs.filter((job) => Boolean(job)).sort((a, b) => a.capturedAt.localeCompare(b.capturedAt));
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (error?.code === "ENOENT") {
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async function prunePendingFinalizeJobs() {
|
|
563
|
+
const jobs = await listPendingFinalizeJobs();
|
|
564
|
+
await Promise.all(
|
|
565
|
+
jobs.filter((job) => job.status === "completed" || isTerminalFailureExpired(job)).map((job) => removePendingFinalizeJob(job.id))
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
async function summarizePendingFinalizeJobs(scope) {
|
|
569
|
+
const jobs = (await listPendingFinalizeJobs()).filter((job) => matchesJobScope(job, scope));
|
|
570
|
+
const summary = createEmptyPendingFinalizeQueueSummary();
|
|
571
|
+
const relevantJobs = jobs.filter((job) => job.status !== "completed");
|
|
572
|
+
if (relevantJobs.length === 0) return summary;
|
|
573
|
+
summary.oldestCapturedAt = relevantJobs[0]?.capturedAt ?? null;
|
|
574
|
+
summary.newestCapturedAt = relevantJobs[relevantJobs.length - 1]?.capturedAt ?? null;
|
|
575
|
+
for (const job of relevantJobs) {
|
|
576
|
+
if (job.error) {
|
|
577
|
+
summary.latestError = job.error;
|
|
578
|
+
}
|
|
579
|
+
if (job.nextRetryAt && (!summary.nextRetryAt || job.nextRetryAt < summary.nextRetryAt)) {
|
|
580
|
+
summary.nextRetryAt = job.nextRetryAt;
|
|
581
|
+
}
|
|
582
|
+
if (job.status === "processing") {
|
|
583
|
+
summary.processingJobCount += 1;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (job.status === "failed") {
|
|
587
|
+
summary.failedJobCount += 1;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (!isPastDue(job.nextRetryAt)) {
|
|
591
|
+
summary.retryScheduledJobCount += 1;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
summary.queuedJobCount += 1;
|
|
595
|
+
}
|
|
596
|
+
summary.activeJobCount = summary.queuedJobCount + summary.processingJobCount + summary.retryScheduledJobCount;
|
|
597
|
+
if (summary.processingJobCount > 0) {
|
|
598
|
+
summary.state = "processing";
|
|
599
|
+
} else if (summary.queuedJobCount > 0) {
|
|
600
|
+
summary.state = "queued";
|
|
601
|
+
} else if (summary.retryScheduledJobCount > 0) {
|
|
602
|
+
summary.state = "retry_scheduled";
|
|
603
|
+
} else if (summary.failedJobCount > 0) {
|
|
604
|
+
summary.state = "failed";
|
|
605
|
+
}
|
|
606
|
+
return summary;
|
|
607
|
+
}
|
|
608
|
+
async function updatePendingFinalizeJob(jobId, update) {
|
|
609
|
+
const existing = await readPendingFinalizeJob(jobId);
|
|
610
|
+
if (!existing) return null;
|
|
611
|
+
const next = {
|
|
612
|
+
...existing,
|
|
613
|
+
...update,
|
|
614
|
+
schemaVersion: 1,
|
|
615
|
+
id: existing.id,
|
|
616
|
+
capturedAt: existing.capturedAt,
|
|
617
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
618
|
+
metadata: update.metadata ? { ...existing.metadata, ...update.metadata } : existing.metadata
|
|
619
|
+
};
|
|
620
|
+
await writeJsonAtomic(getJobPath(jobId), next);
|
|
621
|
+
return next;
|
|
622
|
+
}
|
|
623
|
+
async function claimPendingFinalizeJob(jobId) {
|
|
624
|
+
const lockPath = getJobLockPath(jobId);
|
|
625
|
+
const lockAcquired = await acquireJobLock(jobId);
|
|
626
|
+
if (!lockAcquired) return null;
|
|
627
|
+
let released = false;
|
|
628
|
+
const release = async () => {
|
|
629
|
+
if (released) return;
|
|
630
|
+
released = true;
|
|
631
|
+
await fs3.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
|
|
632
|
+
};
|
|
633
|
+
try {
|
|
634
|
+
let existing = await readPendingFinalizeJob(jobId);
|
|
635
|
+
if (!existing) {
|
|
636
|
+
await release();
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
if (isStaleAttempt(existing)) {
|
|
640
|
+
const recovered = await updatePendingFinalizeJob(jobId, {
|
|
641
|
+
status: "queued",
|
|
642
|
+
error: existing.error ?? "Recovered a stale finalize processing lease.",
|
|
643
|
+
nextRetryAt: null
|
|
644
|
+
});
|
|
645
|
+
existing = recovered ?? existing;
|
|
646
|
+
}
|
|
647
|
+
if (existing.status === "failed") {
|
|
648
|
+
if (isTerminalFailure(existing)) {
|
|
649
|
+
await release();
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const recovered = await updatePendingFinalizeJob(jobId, {
|
|
653
|
+
status: "queued",
|
|
654
|
+
nextRetryAt: existing.nextRetryAt ?? null
|
|
655
|
+
});
|
|
656
|
+
existing = recovered ?? existing;
|
|
657
|
+
}
|
|
658
|
+
if (existing.status !== "queued" || !isPastDue(existing.nextRetryAt)) {
|
|
659
|
+
await release();
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
663
|
+
const claimed = await updatePendingFinalizeJob(jobId, {
|
|
664
|
+
status: "processing",
|
|
665
|
+
error: null,
|
|
666
|
+
retryCount: existing.retryCount + 1,
|
|
667
|
+
lastAttemptAt: now,
|
|
668
|
+
nextRetryAt: null
|
|
669
|
+
});
|
|
670
|
+
if (!claimed) {
|
|
671
|
+
await release();
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
return { job: claimed, release };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
await release();
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async function removePendingFinalizeJob(jobId) {
|
|
681
|
+
try {
|
|
682
|
+
await fs3.unlink(getJobPath(jobId));
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error?.code !== "ENOENT") {
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
await fs3.rm(getJobLockPath(jobId), { recursive: true, force: true }).catch(() => void 0);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/application/collab/shared.ts
|
|
692
|
+
import { createHash as createHash3 } from "crypto";
|
|
91
693
|
function unwrapResponseObject(resp, label) {
|
|
92
694
|
const obj = resp?.responseObject;
|
|
93
695
|
if (obj === void 0 || obj === null) {
|
|
@@ -126,7 +728,7 @@ function sleep(ms) {
|
|
|
126
728
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
127
729
|
}
|
|
128
730
|
function buildDeterministicIdempotencyKey(parts) {
|
|
129
|
-
return
|
|
731
|
+
return createHash3("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
130
732
|
}
|
|
131
733
|
function formatCliErrorDetail(err) {
|
|
132
734
|
if (err instanceof RemixError) {
|
|
@@ -567,974 +1169,1009 @@ async function ensureActiveLaneBinding(params) {
|
|
|
567
1169
|
});
|
|
568
1170
|
}
|
|
569
1171
|
|
|
570
|
-
// src/application/collab/
|
|
571
|
-
|
|
1172
|
+
// src/application/collab/collabDetectRepoState.ts
|
|
1173
|
+
function buildBaseState() {
|
|
1174
|
+
return {
|
|
1175
|
+
status: "ready",
|
|
1176
|
+
repoState: null,
|
|
1177
|
+
repoRoot: null,
|
|
1178
|
+
binding: null,
|
|
1179
|
+
currentBranch: null,
|
|
1180
|
+
branchName: null,
|
|
1181
|
+
localCommitHash: null,
|
|
1182
|
+
currentSnapshotHash: null,
|
|
1183
|
+
currentServerHeadHash: null,
|
|
1184
|
+
currentServerHeadCommitId: null,
|
|
1185
|
+
worktreeClean: false,
|
|
1186
|
+
pendingFinalize: {
|
|
1187
|
+
state: "idle",
|
|
1188
|
+
activeJobCount: 0,
|
|
1189
|
+
queuedJobCount: 0,
|
|
1190
|
+
processingJobCount: 0,
|
|
1191
|
+
retryScheduledJobCount: 0,
|
|
1192
|
+
failedJobCount: 0,
|
|
1193
|
+
oldestCapturedAt: null,
|
|
1194
|
+
newestCapturedAt: null,
|
|
1195
|
+
nextRetryAt: null,
|
|
1196
|
+
latestError: null
|
|
1197
|
+
},
|
|
1198
|
+
warnings: [],
|
|
1199
|
+
hint: null,
|
|
1200
|
+
metadataWarnings: [],
|
|
1201
|
+
baseline: {
|
|
1202
|
+
lastSnapshotId: null,
|
|
1203
|
+
lastSnapshotHash: null,
|
|
1204
|
+
lastServerHeadHash: null,
|
|
1205
|
+
lastSeenLocalCommitHash: null
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
async function collabDetectRepoState(params) {
|
|
1210
|
+
const detected = buildBaseState();
|
|
572
1211
|
let repoRoot;
|
|
573
1212
|
try {
|
|
574
1213
|
repoRoot = await findGitRoot(params.cwd);
|
|
575
1214
|
} catch (error) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
repoRoot: null,
|
|
580
|
-
appId: null,
|
|
581
|
-
currentBranch: null,
|
|
582
|
-
branchName: null,
|
|
583
|
-
headCommitHash: null,
|
|
584
|
-
worktreeClean: false,
|
|
585
|
-
syncStatus: null,
|
|
586
|
-
syncTargetCommitHash: null,
|
|
587
|
-
syncTargetCommitId: null,
|
|
588
|
-
reconcileTargetHeadCommitHash: null,
|
|
589
|
-
reconcileTargetHeadCommitId: null,
|
|
590
|
-
warnings: [],
|
|
591
|
-
hint: message
|
|
592
|
-
};
|
|
1215
|
+
detected.status = "not_git_repo";
|
|
1216
|
+
detected.hint = formatCliErrorDetail(error) ?? "Not inside a git repository.";
|
|
1217
|
+
return detected;
|
|
593
1218
|
}
|
|
594
|
-
|
|
1219
|
+
detected.repoRoot = repoRoot;
|
|
1220
|
+
const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api ?? void 0 });
|
|
595
1221
|
if (bindingResolution.status === "not_bound") {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
currentBranch: null,
|
|
601
|
-
branchName: null,
|
|
602
|
-
headCommitHash: null,
|
|
603
|
-
worktreeClean: false,
|
|
604
|
-
syncStatus: null,
|
|
605
|
-
syncTargetCommitHash: null,
|
|
606
|
-
syncTargetCommitId: null,
|
|
607
|
-
reconcileTargetHeadCommitHash: null,
|
|
608
|
-
reconcileTargetHeadCommitId: null,
|
|
609
|
-
warnings: [],
|
|
610
|
-
hint: "Run `remix collab init` first."
|
|
611
|
-
};
|
|
1222
|
+
detected.status = "not_bound";
|
|
1223
|
+
detected.repoState = "binding_problem";
|
|
1224
|
+
detected.hint = "Run `remix collab init` first.";
|
|
1225
|
+
return detected;
|
|
612
1226
|
}
|
|
613
1227
|
if (bindingResolution.status === "missing_branch_binding") {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
headCommitHash: null,
|
|
621
|
-
worktreeClean: false,
|
|
622
|
-
syncStatus: null,
|
|
623
|
-
syncTargetCommitHash: null,
|
|
624
|
-
syncTargetCommitId: null,
|
|
625
|
-
reconcileTargetHeadCommitHash: null,
|
|
626
|
-
reconcileTargetHeadCommitId: null,
|
|
627
|
-
warnings: [],
|
|
628
|
-
hint: `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`
|
|
629
|
-
};
|
|
1228
|
+
detected.status = "branch_binding_missing";
|
|
1229
|
+
detected.repoState = "binding_problem";
|
|
1230
|
+
detected.currentBranch = bindingResolution.currentBranch;
|
|
1231
|
+
detected.branchName = bindingResolution.currentBranch;
|
|
1232
|
+
detected.hint = `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`;
|
|
1233
|
+
return detected;
|
|
630
1234
|
}
|
|
631
1235
|
if (bindingResolution.status === "ambiguous_family_selection") {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
headCommitHash: null,
|
|
639
|
-
worktreeClean: false,
|
|
640
|
-
syncStatus: null,
|
|
641
|
-
syncTargetCommitHash: null,
|
|
642
|
-
syncTargetCommitId: null,
|
|
643
|
-
reconcileTargetHeadCommitHash: null,
|
|
644
|
-
reconcileTargetHeadCommitId: null,
|
|
645
|
-
warnings: [],
|
|
646
|
-
hint: "Multiple canonical Remix families match this repository. Continue from a checkout already bound to the intended family, or run `remix collab init --force-new` to create a new canonical family."
|
|
647
|
-
};
|
|
1236
|
+
detected.status = "family_ambiguous";
|
|
1237
|
+
detected.repoState = "binding_problem";
|
|
1238
|
+
detected.currentBranch = bindingResolution.currentBranch;
|
|
1239
|
+
detected.branchName = bindingResolution.currentBranch;
|
|
1240
|
+
detected.hint = "Multiple canonical Remix families match this repository. Continue from a checkout already bound to the intended family, or run `remix collab init --force-new`.";
|
|
1241
|
+
return detected;
|
|
648
1242
|
}
|
|
649
1243
|
if (bindingResolution.status === "binding_conflict") {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
worktreeClean: false,
|
|
658
|
-
syncStatus: null,
|
|
659
|
-
syncTargetCommitHash: null,
|
|
660
|
-
syncTargetCommitId: null,
|
|
661
|
-
reconcileTargetHeadCommitHash: null,
|
|
662
|
-
reconcileTargetHeadCommitId: null,
|
|
663
|
-
warnings: [],
|
|
664
|
-
hint: `Local binding for ${bindingResolution.currentBranch ?? "(detached)"} points to app ${bindingResolution.binding.currentAppId}, but the server resolved lane ${bindingResolution.resolvedLane.laneId ?? "(unknown)"} / app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before recording work.`
|
|
665
|
-
};
|
|
1244
|
+
detected.status = "metadata_conflict";
|
|
1245
|
+
detected.repoState = "metadata_conflict";
|
|
1246
|
+
detected.binding = bindingResolution.binding;
|
|
1247
|
+
detected.currentBranch = bindingResolution.currentBranch;
|
|
1248
|
+
detected.branchName = bindingResolution.binding.branchName;
|
|
1249
|
+
detected.hint = `Local binding for ${bindingResolution.currentBranch ?? "(detached)"} points to app ${bindingResolution.binding.currentAppId}, but the server resolved lane ${bindingResolution.resolvedLane.laneId ?? "(unknown)"} / app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`;
|
|
1250
|
+
return detected;
|
|
666
1251
|
}
|
|
667
1252
|
const binding = bindingResolution.binding;
|
|
668
|
-
|
|
1253
|
+
detected.binding = binding;
|
|
1254
|
+
const [currentBranch, localCommitHash, worktreeStatus] = await Promise.all([
|
|
669
1255
|
getCurrentBranch(repoRoot),
|
|
670
1256
|
getHeadCommitHash(repoRoot),
|
|
671
1257
|
getWorktreeStatus(repoRoot)
|
|
672
1258
|
]);
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
worktreeClean: worktreeStatus.isClean,
|
|
683
|
-
syncStatus: null,
|
|
684
|
-
syncTargetCommitHash: null,
|
|
685
|
-
syncTargetCommitId: null,
|
|
686
|
-
reconcileTargetHeadCommitHash: null,
|
|
687
|
-
reconcileTargetHeadCommitId: null,
|
|
688
|
-
warnings: [],
|
|
689
|
-
hint: "Failed to resolve local HEAD commit."
|
|
690
|
-
};
|
|
1259
|
+
detected.currentBranch = currentBranch;
|
|
1260
|
+
detected.branchName = binding.branchName ?? null;
|
|
1261
|
+
detected.localCommitHash = localCommitHash;
|
|
1262
|
+
detected.worktreeClean = worktreeStatus.isClean;
|
|
1263
|
+
if (!localCommitHash) {
|
|
1264
|
+
detected.status = "missing_head";
|
|
1265
|
+
detected.repoState = "binding_problem";
|
|
1266
|
+
detected.hint = "Failed to resolve local HEAD commit.";
|
|
1267
|
+
return detected;
|
|
691
1268
|
}
|
|
692
|
-
if (!params.allowBranchMismatch && !isBoundBranchMatch(currentBranch, branchName)) {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
currentBranch,
|
|
698
|
-
branchName,
|
|
699
|
-
headCommitHash,
|
|
700
|
-
worktreeClean: worktreeStatus.isClean,
|
|
701
|
-
syncStatus: null,
|
|
702
|
-
syncTargetCommitHash: null,
|
|
703
|
-
syncTargetCommitId: null,
|
|
704
|
-
reconcileTargetHeadCommitHash: null,
|
|
705
|
-
reconcileTargetHeadCommitId: null,
|
|
706
|
-
warnings: [],
|
|
707
|
-
hint: buildBranchMismatchHint({
|
|
708
|
-
currentBranch,
|
|
709
|
-
branchName
|
|
710
|
-
})
|
|
711
|
-
};
|
|
1269
|
+
if (!params.allowBranchMismatch && !isBoundBranchMatch(currentBranch, binding.branchName ?? null)) {
|
|
1270
|
+
detected.status = "branch_mismatch";
|
|
1271
|
+
detected.repoState = "binding_problem";
|
|
1272
|
+
detected.hint = buildBranchMismatchHint({ currentBranch, branchName: binding.branchName ?? null });
|
|
1273
|
+
return detected;
|
|
712
1274
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
syncTargetCommitId: sync.targetCommitId,
|
|
733
|
-
reconcileTargetHeadCommitHash: null,
|
|
734
|
-
reconcileTargetHeadCommitId: null,
|
|
735
|
-
warnings: sync.warnings,
|
|
736
|
-
hint: sync.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
if (sync.status === "up_to_date" || sync.status === "ready_to_fast_forward") {
|
|
740
|
-
return {
|
|
741
|
-
status: sync.status,
|
|
742
|
-
repoRoot,
|
|
743
|
-
appId: binding.currentAppId,
|
|
744
|
-
currentBranch,
|
|
745
|
-
branchName,
|
|
746
|
-
headCommitHash,
|
|
747
|
-
worktreeClean: worktreeStatus.isClean,
|
|
748
|
-
syncStatus: sync.status,
|
|
749
|
-
syncTargetCommitHash: sync.targetCommitHash,
|
|
750
|
-
syncTargetCommitId: sync.targetCommitId,
|
|
751
|
-
reconcileTargetHeadCommitHash: null,
|
|
752
|
-
reconcileTargetHeadCommitId: null,
|
|
753
|
-
warnings: sync.warnings,
|
|
754
|
-
hint: null
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
const reconcileResp = await params.api.preflightAppReconcile(binding.currentAppId, {
|
|
758
|
-
localHeadCommitHash: headCommitHash,
|
|
759
|
-
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
760
|
-
remoteUrl: binding.remoteUrl ?? void 0,
|
|
761
|
-
defaultBranch: binding.defaultBranch ?? void 0
|
|
762
|
-
});
|
|
763
|
-
const reconcile = unwrapResponseObject(reconcileResp, "reconcile preflight");
|
|
764
|
-
if (reconcile.status === "metadata_conflict") {
|
|
765
|
-
return {
|
|
766
|
-
status: "metadata_conflict",
|
|
767
|
-
repoRoot,
|
|
768
|
-
appId: binding.currentAppId,
|
|
769
|
-
currentBranch,
|
|
770
|
-
branchName,
|
|
771
|
-
headCommitHash,
|
|
772
|
-
worktreeClean: worktreeStatus.isClean,
|
|
773
|
-
syncStatus: sync.status,
|
|
774
|
-
syncTargetCommitHash: sync.targetCommitHash,
|
|
775
|
-
syncTargetCommitId: sync.targetCommitId,
|
|
776
|
-
reconcileTargetHeadCommitHash: reconcile.targetHeadCommitHash,
|
|
777
|
-
reconcileTargetHeadCommitId: reconcile.targetHeadCommitId,
|
|
778
|
-
warnings: reconcile.warnings,
|
|
779
|
-
hint: reconcile.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
if (reconcile.status === "up_to_date") {
|
|
783
|
-
return {
|
|
784
|
-
status: "up_to_date",
|
|
785
|
-
repoRoot,
|
|
786
|
-
appId: binding.currentAppId,
|
|
787
|
-
currentBranch,
|
|
788
|
-
branchName,
|
|
789
|
-
headCommitHash,
|
|
790
|
-
worktreeClean: worktreeStatus.isClean,
|
|
791
|
-
syncStatus: sync.status,
|
|
792
|
-
syncTargetCommitHash: sync.targetCommitHash,
|
|
793
|
-
syncTargetCommitId: sync.targetCommitId,
|
|
794
|
-
reconcileTargetHeadCommitHash: reconcile.targetHeadCommitHash,
|
|
795
|
-
reconcileTargetHeadCommitId: reconcile.targetHeadCommitId,
|
|
796
|
-
warnings: reconcile.warnings,
|
|
797
|
-
hint: null
|
|
798
|
-
};
|
|
1275
|
+
if (!params.api) {
|
|
1276
|
+
const [inspection, pendingFinalize] = await Promise.all([
|
|
1277
|
+
inspectLocalSnapshot({
|
|
1278
|
+
repoRoot,
|
|
1279
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1280
|
+
laneId: binding.laneId,
|
|
1281
|
+
branchName: binding.branchName,
|
|
1282
|
+
persistBlobs: false
|
|
1283
|
+
}),
|
|
1284
|
+
summarizePendingFinalizeJobs({
|
|
1285
|
+
repoRoot,
|
|
1286
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1287
|
+
currentAppId: binding.currentAppId,
|
|
1288
|
+
laneId: binding.laneId
|
|
1289
|
+
})
|
|
1290
|
+
]);
|
|
1291
|
+
detected.currentSnapshotHash = inspection.snapshotHash;
|
|
1292
|
+
detected.pendingFinalize = pendingFinalize;
|
|
1293
|
+
return detected;
|
|
799
1294
|
}
|
|
800
|
-
return {
|
|
801
|
-
status: "reconcile_required",
|
|
802
|
-
repoRoot,
|
|
803
|
-
appId: binding.currentAppId,
|
|
804
|
-
currentBranch,
|
|
805
|
-
branchName,
|
|
806
|
-
headCommitHash,
|
|
807
|
-
worktreeClean: worktreeStatus.isClean,
|
|
808
|
-
syncStatus: sync.status,
|
|
809
|
-
syncTargetCommitHash: sync.targetCommitHash,
|
|
810
|
-
syncTargetCommitId: sync.targetCommitId,
|
|
811
|
-
reconcileTargetHeadCommitHash: reconcile.targetHeadCommitHash,
|
|
812
|
-
reconcileTargetHeadCommitId: reconcile.targetHeadCommitId,
|
|
813
|
-
warnings: reconcile.warnings,
|
|
814
|
-
hint: reconcile.warnings.join("\n") || "Run `remix collab reconcile` first because the local history is no longer fast-forward compatible with the app."
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// src/infrastructure/locking/repoMutationLock.ts
|
|
819
|
-
import fs from "fs/promises";
|
|
820
|
-
import os from "os";
|
|
821
|
-
import path from "path";
|
|
822
|
-
var DEFAULT_ACQUIRE_TIMEOUT_MS = 15e3;
|
|
823
|
-
var DEFAULT_STALE_MS = 45e3;
|
|
824
|
-
var DEFAULT_HEARTBEAT_MS = 5e3;
|
|
825
|
-
var RETRY_DELAY_MS = 250;
|
|
826
|
-
var heldLocks = /* @__PURE__ */ new Map();
|
|
827
|
-
function sleep2(ms) {
|
|
828
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
829
|
-
}
|
|
830
|
-
function createOwner(params) {
|
|
831
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
832
|
-
return {
|
|
833
|
-
operation: params.operation,
|
|
834
|
-
repoRoot: params.repoRoot,
|
|
835
|
-
pid: process.pid,
|
|
836
|
-
hostname: os.hostname(),
|
|
837
|
-
startedAt: now,
|
|
838
|
-
heartbeatAt: now,
|
|
839
|
-
version: process.version,
|
|
840
|
-
requestId: params.requestId?.trim() || null
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
|
-
async function writeOwnerMetadata(ownerPath, owner) {
|
|
844
|
-
await fs.writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}
|
|
845
|
-
`, "utf8");
|
|
846
|
-
}
|
|
847
|
-
async function readOwnerMetadata(ownerPath) {
|
|
848
1295
|
try {
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1296
|
+
const [headResp, inspection, baseline, pendingFinalize] = await Promise.all([
|
|
1297
|
+
params.api.getAppHead(binding.currentAppId),
|
|
1298
|
+
inspectLocalSnapshot({
|
|
1299
|
+
repoRoot,
|
|
1300
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1301
|
+
laneId: binding.laneId,
|
|
1302
|
+
branchName: binding.branchName,
|
|
1303
|
+
persistBlobs: false
|
|
1304
|
+
}),
|
|
1305
|
+
readLocalBaseline({
|
|
1306
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1307
|
+
laneId: binding.laneId,
|
|
1308
|
+
repoRoot
|
|
1309
|
+
}),
|
|
1310
|
+
summarizePendingFinalizeJobs({
|
|
1311
|
+
repoRoot,
|
|
1312
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1313
|
+
currentAppId: binding.currentAppId,
|
|
1314
|
+
laneId: binding.laneId
|
|
1315
|
+
})
|
|
1316
|
+
]);
|
|
1317
|
+
const appHead = unwrapResponseObject(headResp, "app head");
|
|
1318
|
+
detected.currentServerHeadHash = appHead.headCommitHash;
|
|
1319
|
+
detected.currentServerHeadCommitId = appHead.headCommitId;
|
|
1320
|
+
detected.currentSnapshotHash = inspection.snapshotHash;
|
|
1321
|
+
detected.pendingFinalize = pendingFinalize;
|
|
1322
|
+
detected.baseline = {
|
|
1323
|
+
lastSnapshotId: baseline?.lastSnapshotId ?? null,
|
|
1324
|
+
lastSnapshotHash: baseline?.lastSnapshotHash ?? null,
|
|
1325
|
+
lastServerHeadHash: baseline?.lastServerHeadHash ?? null,
|
|
1326
|
+
lastSeenLocalCommitHash: baseline?.lastSeenLocalCommitHash ?? null
|
|
864
1327
|
};
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
async function tryAcquireLock(lockDir, ownerPath, owner) {
|
|
896
|
-
try {
|
|
897
|
-
await ensureLockDir(lockDir);
|
|
898
|
-
await fs.mkdir(lockDir);
|
|
899
|
-
try {
|
|
900
|
-
await writeOwnerMetadata(ownerPath, owner);
|
|
901
|
-
} catch (error) {
|
|
902
|
-
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
903
|
-
throw error;
|
|
1328
|
+
if (!baseline?.lastSnapshotHash || !baseline.lastServerHeadHash) {
|
|
1329
|
+
if (detected.worktreeClean && localCommitHash && localCommitHash !== appHead.headCommitHash) {
|
|
1330
|
+
try {
|
|
1331
|
+
const bootstrapResp = await params.api.getAppDelta(binding.currentAppId, {
|
|
1332
|
+
baseHeadHash: localCommitHash,
|
|
1333
|
+
targetHeadHash: appHead.headCommitHash,
|
|
1334
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1335
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1336
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
1337
|
+
});
|
|
1338
|
+
const bootstrapDelta = unwrapResponseObject(bootstrapResp, "app delta");
|
|
1339
|
+
detected.metadataWarnings = Array.from(/* @__PURE__ */ new Set([...detected.metadataWarnings, ...bootstrapDelta.warnings]));
|
|
1340
|
+
detected.warnings.push(...bootstrapDelta.warnings);
|
|
1341
|
+
if (bootstrapDelta.status === "conflict_risk") {
|
|
1342
|
+
detected.status = "metadata_conflict";
|
|
1343
|
+
detected.repoState = "metadata_conflict";
|
|
1344
|
+
detected.hint = bootstrapDelta.warnings.join("\n") || "Run the command from the correct bound repository.";
|
|
1345
|
+
return detected;
|
|
1346
|
+
}
|
|
1347
|
+
if (bootstrapDelta.status === "delta_ready" || bootstrapDelta.status === "up_to_date") {
|
|
1348
|
+
detected.repoState = "server_only_changed";
|
|
1349
|
+
detected.hint = "This checkout has not stored a local Remix baseline yet, but its current Git HEAD is already known to Remix. Pull the server delta locally to create the first baseline for this checkout.";
|
|
1350
|
+
return detected;
|
|
1351
|
+
}
|
|
1352
|
+
} catch {
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
detected.repoState = "external_local_base_changed";
|
|
1356
|
+
detected.hint = "No local Remix baseline exists for this lane yet. Run `remix collab re-anchor` to anchor this checkout.";
|
|
1357
|
+
return detected;
|
|
904
1358
|
}
|
|
905
|
-
|
|
1359
|
+
const localHeadMovedSinceBaseline = Boolean(baseline.lastSeenLocalCommitHash) && localCommitHash !== baseline.lastSeenLocalCommitHash;
|
|
1360
|
+
if (localHeadMovedSinceBaseline) {
|
|
1361
|
+
detected.warnings.push(
|
|
1362
|
+
"Local Git HEAD changed since the last Remix baseline. Remix will use the current workspace snapshot to detect divergence."
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
const metadataBaseHeadHash = baseline.lastServerHeadHash || appHead.headCommitHash;
|
|
1366
|
+
const metadataResp = await params.api.getAppDelta(binding.currentAppId, {
|
|
1367
|
+
baseHeadHash: metadataBaseHeadHash,
|
|
1368
|
+
targetHeadHash: metadataBaseHeadHash,
|
|
1369
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1370
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1371
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
1372
|
+
});
|
|
1373
|
+
const metadataCheck = unwrapResponseObject(metadataResp, "app delta metadata");
|
|
1374
|
+
detected.metadataWarnings = metadataCheck.warnings;
|
|
1375
|
+
detected.warnings.push(...metadataCheck.warnings);
|
|
1376
|
+
if (metadataCheck.status === "conflict_risk") {
|
|
1377
|
+
detected.status = "metadata_conflict";
|
|
1378
|
+
detected.repoState = "metadata_conflict";
|
|
1379
|
+
detected.hint = metadataCheck.warnings.join("\n") || "Run the command from the correct bound repository.";
|
|
1380
|
+
return detected;
|
|
1381
|
+
}
|
|
1382
|
+
const localChanged = inspection.snapshotHash !== baseline.lastSnapshotHash;
|
|
1383
|
+
const serverChanged = appHead.headCommitHash !== baseline.lastServerHeadHash;
|
|
1384
|
+
if (!localChanged && !serverChanged) {
|
|
1385
|
+
detected.repoState = "idle";
|
|
1386
|
+
return detected;
|
|
1387
|
+
}
|
|
1388
|
+
if (localChanged && !serverChanged) {
|
|
1389
|
+
detected.repoState = "local_only_changed";
|
|
1390
|
+
return detected;
|
|
1391
|
+
}
|
|
1392
|
+
if (!localChanged && serverChanged) {
|
|
1393
|
+
detected.repoState = "server_only_changed";
|
|
1394
|
+
detected.hint = "The server lane advanced since the last agreed baseline. Pull the server delta locally before continuing.";
|
|
1395
|
+
return detected;
|
|
1396
|
+
}
|
|
1397
|
+
detected.repoState = "both_changed";
|
|
1398
|
+
detected.hint = "Both the local workspace and the server lane changed since the last agreed baseline. Replay or reconcile is required before normal recording continues.";
|
|
1399
|
+
return detected;
|
|
906
1400
|
} catch (error) {
|
|
907
|
-
|
|
908
|
-
|
|
1401
|
+
detected.status = "remote_error";
|
|
1402
|
+
detected.hint = formatCliErrorDetail(error) ?? "Failed to detect the current Remix repo state.";
|
|
1403
|
+
return detected;
|
|
909
1404
|
}
|
|
910
1405
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
params.owner ? `Heartbeat at: ${params.owner.heartbeatAt}` : null,
|
|
919
|
-
`Waited ${params.waitedMs}ms for the repo mutation lock.`,
|
|
920
|
-
`Stale lock threshold: ${params.staleMs}ms.`,
|
|
921
|
-
"Retry after the active operation finishes. If the process crashed, wait for stale lock recovery or remove the stale lock manually if necessary."
|
|
922
|
-
];
|
|
923
|
-
return lines.filter(Boolean).join("\n");
|
|
1406
|
+
|
|
1407
|
+
// src/application/collab/collabFinalizeProcessing.ts
|
|
1408
|
+
var FINALIZE_RETRY_BASE_DELAY_MS = 15e3;
|
|
1409
|
+
var FINALIZE_RETRY_MAX_DELAY_MS = 5 * 60 * 1e3;
|
|
1410
|
+
function readMetadataString(job, key) {
|
|
1411
|
+
const value = job.metadata[key];
|
|
1412
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
924
1413
|
}
|
|
925
|
-
function
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
}
|
|
929
|
-
return `operation=${owner.operation} pid=${owner.pid} host=${owner.hostname} startedAt=${owner.startedAt} heartbeatAt=${owner.heartbeatAt}`;
|
|
1414
|
+
function readMetadataActor(job) {
|
|
1415
|
+
const actor = job.metadata.actor;
|
|
1416
|
+
return actor && typeof actor === "object" ? actor : void 0;
|
|
930
1417
|
}
|
|
931
|
-
function
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
message: `[${REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED}] Recovered a stale Remix repo mutation lock (${formatOwnerSummary(owner)}).`
|
|
936
|
-
};
|
|
1418
|
+
function buildNextRetryAt(retryCount) {
|
|
1419
|
+
const exponent = Math.max(0, retryCount - 1);
|
|
1420
|
+
const delayMs = Math.min(FINALIZE_RETRY_BASE_DELAY_MS * 2 ** exponent, FINALIZE_RETRY_MAX_DELAY_MS);
|
|
1421
|
+
return new Date(Date.now() + delayMs).toISOString();
|
|
937
1422
|
}
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
while (Date.now() - startedAt < options.acquireTimeoutMs) {
|
|
943
|
-
if (await tryAcquireLock(lockDir, ownerPath, owner)) return notices;
|
|
944
|
-
const currentOwner2 = await readOwnerMetadata(ownerPath);
|
|
945
|
-
observedHeldLock = true;
|
|
946
|
-
const lastUpdateMs = await getLastKnownUpdateMs(lockDir, ownerPath, currentOwner2);
|
|
947
|
-
const ageMs = Math.max(0, Date.now() - lastUpdateMs);
|
|
948
|
-
const alive = await isProcessAlive(currentOwner2);
|
|
949
|
-
if (ageMs >= options.staleMs && alive !== true) {
|
|
950
|
-
notices.push(buildStaleRecoveryNotice(currentOwner2));
|
|
951
|
-
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
952
|
-
continue;
|
|
953
|
-
}
|
|
954
|
-
await sleep2(RETRY_DELAY_MS);
|
|
955
|
-
}
|
|
956
|
-
const currentOwner = await readOwnerMetadata(ownerPath);
|
|
957
|
-
throw new RemixError("Repository is busy with another Remix mutation.", {
|
|
958
|
-
code: REMIX_ERROR_CODES.REPO_LOCK_TIMEOUT,
|
|
959
|
-
exitCode: 2,
|
|
960
|
-
hint: formatLockHint({
|
|
961
|
-
owner: currentOwner,
|
|
962
|
-
waitedMs: Date.now() - startedAt,
|
|
963
|
-
staleMs: options.staleMs,
|
|
964
|
-
observedHeldLock
|
|
965
|
-
})
|
|
1423
|
+
function buildFinalizeCliError(params) {
|
|
1424
|
+
const error = new RemixError(params.message, {
|
|
1425
|
+
exitCode: params.exitCode,
|
|
1426
|
+
hint: params.hint
|
|
966
1427
|
});
|
|
1428
|
+
error.finalizeDisposition = params.disposition;
|
|
1429
|
+
error.finalizeReason = params.reason;
|
|
1430
|
+
return error;
|
|
967
1431
|
}
|
|
968
|
-
function
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
void writeOwnerMetadata(ownerPath, nextOwner).catch(() => void 0);
|
|
976
|
-
void fs.utimes(lockDir, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
977
|
-
}, heartbeatMs);
|
|
1432
|
+
function classifyFinalizeError(error) {
|
|
1433
|
+
const tagged = error;
|
|
1434
|
+
return {
|
|
1435
|
+
disposition: tagged.finalizeDisposition ?? "retryable",
|
|
1436
|
+
reason: tagged.finalizeReason ?? "unknown",
|
|
1437
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1438
|
+
};
|
|
978
1439
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1440
|
+
function buildWorkspaceMetadata(params) {
|
|
1441
|
+
return {
|
|
1442
|
+
branch: params.branchName,
|
|
1443
|
+
repoRoot: params.repoRoot,
|
|
1444
|
+
remoteUrl: params.remoteUrl,
|
|
1445
|
+
defaultBranch: params.defaultBranch,
|
|
1446
|
+
recordingMode: "boundary_delta",
|
|
1447
|
+
baselineSnapshotId: params.baselineSnapshotId,
|
|
1448
|
+
currentSnapshotId: params.currentSnapshotId,
|
|
1449
|
+
baselineServerHeadHash: params.baselineServerHeadHash,
|
|
1450
|
+
currentSnapshotHash: params.currentSnapshotHash,
|
|
1451
|
+
localCommitHash: params.localCommitHash,
|
|
1452
|
+
repoStateAtCapture: params.repoState,
|
|
1453
|
+
replayedFromBaseHash: params.replayedFromBaseHash ?? null
|
|
1454
|
+
};
|
|
987
1455
|
}
|
|
988
|
-
async function
|
|
989
|
-
const
|
|
990
|
-
const gitCommonDir = await getGitCommonDir(repoRoot);
|
|
991
|
-
const lockDir = path.join(gitCommonDir, "remix", "locks", "repo-mutation.lock");
|
|
992
|
-
const owner = createOwner({
|
|
993
|
-
operation: options.operation,
|
|
994
|
-
repoRoot,
|
|
995
|
-
requestId: options.requestId
|
|
996
|
-
});
|
|
997
|
-
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
998
|
-
const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
999
|
-
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
1000
|
-
const existing = heldLocks.get(lockDir);
|
|
1001
|
-
let notices = [];
|
|
1002
|
-
if (!existing) {
|
|
1003
|
-
notices = await acquirePhysicalLock(lockDir, path.join(lockDir, "owner.json"), owner, {
|
|
1004
|
-
acquireTimeoutMs,
|
|
1005
|
-
staleMs
|
|
1006
|
-
});
|
|
1007
|
-
const ownerPath = path.join(lockDir, "owner.json");
|
|
1008
|
-
heldLocks.set(lockDir, {
|
|
1009
|
-
count: 1,
|
|
1010
|
-
lockDir,
|
|
1011
|
-
ownerPath,
|
|
1012
|
-
owner,
|
|
1013
|
-
heartbeatTimer: startHeartbeat(lockDir, ownerPath, owner, heartbeatMs)
|
|
1014
|
-
});
|
|
1015
|
-
} else {
|
|
1016
|
-
existing.count += 1;
|
|
1017
|
-
}
|
|
1456
|
+
async function processClaimedPendingFinalizeJob(params) {
|
|
1457
|
+
const job = params.job;
|
|
1018
1458
|
try {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1459
|
+
const [snapshot, baseline, appHeadResp] = await Promise.all([
|
|
1460
|
+
readLocalSnapshot(job.currentSnapshotId),
|
|
1461
|
+
readLocalBaseline({
|
|
1462
|
+
repoFingerprint: job.repoFingerprint,
|
|
1463
|
+
laneId: job.laneId,
|
|
1464
|
+
repoRoot: job.repoRoot
|
|
1465
|
+
}),
|
|
1466
|
+
params.api.getAppHead(job.currentAppId)
|
|
1467
|
+
]);
|
|
1468
|
+
if (!snapshot) {
|
|
1469
|
+
throw buildFinalizeCliError({
|
|
1470
|
+
message: "Captured snapshot is missing from the local snapshot store.",
|
|
1471
|
+
exitCode: 1,
|
|
1472
|
+
disposition: "terminal",
|
|
1473
|
+
reason: "snapshot_missing"
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
if (!baseline) {
|
|
1477
|
+
throw buildFinalizeCliError({
|
|
1478
|
+
message: "Local baseline is missing for this queued finalize job.",
|
|
1479
|
+
exitCode: 2,
|
|
1480
|
+
hint: "Run `remix collab re-anchor` to anchor the repository again.",
|
|
1481
|
+
disposition: "terminal",
|
|
1482
|
+
reason: "baseline_missing"
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
if (baseline.lastSnapshotId !== job.baselineSnapshotId || baseline.lastServerHeadHash !== job.baselineServerHeadHash) {
|
|
1486
|
+
throw buildFinalizeCliError({
|
|
1487
|
+
message: "Finalize queue baseline drifted before this job was processed.",
|
|
1488
|
+
exitCode: 1,
|
|
1489
|
+
hint: "Process queued finalize jobs in capture order, or re-anchor the repository before retrying.",
|
|
1490
|
+
disposition: "terminal",
|
|
1491
|
+
reason: "baseline_drifted"
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
const appHead = unwrapResponseObject(appHeadResp, "app head");
|
|
1495
|
+
const remoteUrl = readMetadataString(job, "remoteUrl");
|
|
1496
|
+
const defaultBranch = readMetadataString(job, "defaultBranch");
|
|
1497
|
+
const repoState = readMetadataString(job, "repoState");
|
|
1498
|
+
const actor = readMetadataActor(job);
|
|
1499
|
+
const diffResult = await diffLocalSnapshots({
|
|
1500
|
+
baseSnapshotId: job.baselineSnapshotId,
|
|
1501
|
+
targetSnapshotId: job.currentSnapshotId
|
|
1025
1502
|
});
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1503
|
+
if (!diffResult.diff.trim()) {
|
|
1504
|
+
if (appHead.headCommitHash !== job.baselineServerHeadHash) {
|
|
1505
|
+
throw buildFinalizeCliError({
|
|
1506
|
+
message: "Server lane changed before a no-diff turn could be recorded.",
|
|
1507
|
+
exitCode: 2,
|
|
1508
|
+
hint: "Pull the server changes locally before recording another no-diff turn.",
|
|
1509
|
+
disposition: "terminal",
|
|
1510
|
+
reason: "server_lane_changed"
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
const collabTurnResp = await params.api.createCollabTurn(job.currentAppId, {
|
|
1514
|
+
threadId: job.threadId ?? void 0,
|
|
1515
|
+
collabLaneId: job.laneId ?? void 0,
|
|
1516
|
+
prompt: job.prompt,
|
|
1517
|
+
assistantResponse: job.assistantResponse,
|
|
1518
|
+
actor,
|
|
1519
|
+
workspaceMetadata: buildWorkspaceMetadata({
|
|
1520
|
+
repoRoot: job.repoRoot,
|
|
1521
|
+
branchName: job.branchName,
|
|
1522
|
+
remoteUrl,
|
|
1523
|
+
defaultBranch,
|
|
1524
|
+
baselineSnapshotId: job.baselineSnapshotId,
|
|
1525
|
+
currentSnapshotId: job.currentSnapshotId,
|
|
1526
|
+
baselineServerHeadHash: job.baselineServerHeadHash,
|
|
1527
|
+
currentSnapshotHash: snapshot.snapshotHash,
|
|
1528
|
+
localCommitHash: snapshot.localCommitHash,
|
|
1529
|
+
repoState
|
|
1530
|
+
}),
|
|
1531
|
+
idempotencyKey: job.idempotencyKey ?? void 0
|
|
1532
|
+
});
|
|
1533
|
+
const collabTurn = unwrapResponseObject(collabTurnResp, "collab turn");
|
|
1534
|
+
await writeLocalBaseline({
|
|
1535
|
+
repoRoot: job.repoRoot,
|
|
1536
|
+
repoFingerprint: job.repoFingerprint,
|
|
1537
|
+
laneId: job.laneId,
|
|
1538
|
+
currentAppId: job.currentAppId,
|
|
1539
|
+
branchName: job.branchName,
|
|
1540
|
+
lastSnapshotId: snapshot.id,
|
|
1541
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
1542
|
+
lastServerHeadHash: appHead.headCommitHash,
|
|
1543
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
1544
|
+
});
|
|
1545
|
+
await updatePendingFinalizeJob(job.id, {
|
|
1546
|
+
status: "completed",
|
|
1547
|
+
metadata: { collabTurnId: collabTurn.id }
|
|
1548
|
+
});
|
|
1549
|
+
return {
|
|
1550
|
+
mode: "no_diff_turn",
|
|
1551
|
+
idempotencyKey: job.idempotencyKey ?? "",
|
|
1552
|
+
queued: false,
|
|
1553
|
+
jobId: job.id,
|
|
1554
|
+
repoState,
|
|
1555
|
+
changeStep: null,
|
|
1556
|
+
collabTurn,
|
|
1557
|
+
autoSync: null,
|
|
1558
|
+
warnings: []
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
let submissionDiff = diffResult.diff;
|
|
1562
|
+
let submissionBaseHeadHash = job.baselineServerHeadHash;
|
|
1563
|
+
let replayedFromBaseHash = null;
|
|
1564
|
+
if (!submissionBaseHeadHash) {
|
|
1565
|
+
throw buildFinalizeCliError({
|
|
1566
|
+
message: "Baseline server head is missing for this finalize job.",
|
|
1567
|
+
exitCode: 1,
|
|
1568
|
+
disposition: "terminal",
|
|
1569
|
+
reason: "baseline_server_head_missing"
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
if (appHead.headCommitHash !== submissionBaseHeadHash) {
|
|
1573
|
+
const replayResp = await params.api.startChangeStepReplay(job.currentAppId, {
|
|
1574
|
+
prompt: job.prompt,
|
|
1575
|
+
assistantResponse: job.assistantResponse,
|
|
1576
|
+
diff: diffResult.diff,
|
|
1577
|
+
baseCommitHash: submissionBaseHeadHash,
|
|
1578
|
+
targetHeadCommitHash: appHead.headCommitHash,
|
|
1579
|
+
expectedPaths: diffResult.changedPaths,
|
|
1580
|
+
actor,
|
|
1581
|
+
workspaceMetadata: buildWorkspaceMetadata({
|
|
1582
|
+
repoRoot: job.repoRoot,
|
|
1583
|
+
branchName: job.branchName,
|
|
1584
|
+
remoteUrl,
|
|
1585
|
+
defaultBranch,
|
|
1586
|
+
baselineSnapshotId: job.baselineSnapshotId,
|
|
1587
|
+
currentSnapshotId: job.currentSnapshotId,
|
|
1588
|
+
baselineServerHeadHash: job.baselineServerHeadHash,
|
|
1589
|
+
currentSnapshotHash: snapshot.snapshotHash,
|
|
1590
|
+
localCommitHash: snapshot.localCommitHash,
|
|
1591
|
+
repoState
|
|
1592
|
+
}),
|
|
1593
|
+
idempotencyKey: buildDeterministicIdempotencyKey({
|
|
1594
|
+
kind: "collab_finalize_turn_replay_v1",
|
|
1595
|
+
appId: job.currentAppId,
|
|
1596
|
+
baseCommitHash: submissionBaseHeadHash,
|
|
1597
|
+
targetHeadCommitHash: appHead.headCommitHash,
|
|
1598
|
+
currentSnapshotId: job.currentSnapshotId,
|
|
1599
|
+
diffSha256: diffResult.diffSha256
|
|
1600
|
+
})
|
|
1601
|
+
});
|
|
1602
|
+
const replayStart = unwrapResponseObject(replayResp, "change step replay");
|
|
1603
|
+
const replay = await pollChangeStepReplay(params.api, job.currentAppId, String(replayStart.id));
|
|
1604
|
+
const replayDiffResp = await params.api.getChangeStepReplayDiff(job.currentAppId, replay.id);
|
|
1605
|
+
const replayDiff = unwrapResponseObject(replayDiffResp, "change step replay diff");
|
|
1606
|
+
submissionDiff = replayDiff.diff;
|
|
1607
|
+
replayedFromBaseHash = submissionBaseHeadHash;
|
|
1608
|
+
submissionBaseHeadHash = appHead.headCommitHash;
|
|
1609
|
+
}
|
|
1610
|
+
const changeStepResp = await params.api.createChangeStep(job.currentAppId, {
|
|
1611
|
+
threadId: job.threadId ?? void 0,
|
|
1612
|
+
collabLaneId: job.laneId ?? void 0,
|
|
1613
|
+
prompt: job.prompt,
|
|
1614
|
+
assistantResponse: job.assistantResponse,
|
|
1615
|
+
diff: submissionDiff,
|
|
1616
|
+
baseCommitHash: submissionBaseHeadHash,
|
|
1617
|
+
headCommitHash: submissionBaseHeadHash,
|
|
1618
|
+
changedFilesCount: diffResult.stats.changedFilesCount,
|
|
1619
|
+
insertions: diffResult.stats.insertions,
|
|
1620
|
+
deletions: diffResult.stats.deletions,
|
|
1621
|
+
actor,
|
|
1622
|
+
workspaceMetadata: buildWorkspaceMetadata({
|
|
1623
|
+
repoRoot: job.repoRoot,
|
|
1624
|
+
branchName: job.branchName,
|
|
1625
|
+
remoteUrl,
|
|
1626
|
+
defaultBranch,
|
|
1627
|
+
baselineSnapshotId: job.baselineSnapshotId,
|
|
1628
|
+
currentSnapshotId: job.currentSnapshotId,
|
|
1629
|
+
baselineServerHeadHash: job.baselineServerHeadHash,
|
|
1630
|
+
currentSnapshotHash: snapshot.snapshotHash,
|
|
1631
|
+
localCommitHash: snapshot.localCommitHash,
|
|
1632
|
+
repoState,
|
|
1633
|
+
replayedFromBaseHash
|
|
1634
|
+
}),
|
|
1635
|
+
idempotencyKey: job.idempotencyKey ?? void 0
|
|
1046
1636
|
});
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
const resp = await params.api.syncLocalApp(binding.currentAppId, {
|
|
1062
|
-
baseCommitHash: headCommitHash,
|
|
1063
|
-
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
1064
|
-
remoteUrl: binding.remoteUrl ?? void 0,
|
|
1065
|
-
defaultBranch: binding.defaultBranch ?? void 0,
|
|
1066
|
-
dryRun: params.dryRun
|
|
1067
|
-
});
|
|
1068
|
-
const sync = unwrapResponseObject(resp, "sync result");
|
|
1069
|
-
if (sync.status === "conflict_risk") {
|
|
1070
|
-
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
1071
|
-
exitCode: 2,
|
|
1072
|
-
hint: sync.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
1637
|
+
const createdStep = unwrapResponseObject(changeStepResp, "change step");
|
|
1638
|
+
const changeStep = await pollChangeStep(params.api, job.currentAppId, String(createdStep.id));
|
|
1639
|
+
const nextHeadResp = await params.api.getAppHead(job.currentAppId);
|
|
1640
|
+
const nextHead = unwrapResponseObject(nextHeadResp, "app head");
|
|
1641
|
+
await writeLocalBaseline({
|
|
1642
|
+
repoRoot: job.repoRoot,
|
|
1643
|
+
repoFingerprint: job.repoFingerprint,
|
|
1644
|
+
laneId: job.laneId,
|
|
1645
|
+
currentAppId: job.currentAppId,
|
|
1646
|
+
branchName: job.branchName,
|
|
1647
|
+
lastSnapshotId: snapshot.id,
|
|
1648
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
1649
|
+
lastServerHeadHash: nextHead.headCommitHash,
|
|
1650
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
1073
1651
|
});
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
exitCode: 2,
|
|
1078
|
-
hint: "Your local HEAD is not on the app sandbox history. Reconcile the repository manually before syncing."
|
|
1652
|
+
await updatePendingFinalizeJob(job.id, {
|
|
1653
|
+
status: "completed",
|
|
1654
|
+
metadata: { changeStepId: String(changeStep.id ?? "") }
|
|
1079
1655
|
});
|
|
1080
|
-
}
|
|
1081
|
-
if (sync.status === "up_to_date") {
|
|
1082
1656
|
return {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
dryRun: params.dryRun
|
|
1657
|
+
mode: "changed_turn",
|
|
1658
|
+
idempotencyKey: job.idempotencyKey ?? "",
|
|
1659
|
+
queued: false,
|
|
1660
|
+
jobId: job.id,
|
|
1661
|
+
repoState,
|
|
1662
|
+
changeStep,
|
|
1663
|
+
collabTurn: null,
|
|
1664
|
+
autoSync: null,
|
|
1665
|
+
warnings: []
|
|
1093
1666
|
};
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
const classified = classifyFinalizeError(error);
|
|
1669
|
+
await updatePendingFinalizeJob(job.id, {
|
|
1670
|
+
status: classified.disposition === "terminal" ? "failed" : "queued",
|
|
1671
|
+
error: classified.message,
|
|
1672
|
+
nextRetryAt: classified.disposition === "terminal" ? null : buildNextRetryAt(job.retryCount),
|
|
1673
|
+
metadata: {
|
|
1674
|
+
failureDisposition: classified.disposition,
|
|
1675
|
+
failureReason: classified.reason
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
throw error;
|
|
1679
|
+
} finally {
|
|
1680
|
+
await params.release();
|
|
1094
1681
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
dryRun: params.dryRun
|
|
1108
|
-
};
|
|
1109
|
-
if (params.dryRun) {
|
|
1110
|
-
return previewResult;
|
|
1111
|
-
}
|
|
1112
|
-
if (!sync.bundleBase64 || !sync.bundleRef) {
|
|
1113
|
-
throw new RemixError("Sync bundle payload is missing.", { exitCode: 1 });
|
|
1114
|
-
}
|
|
1115
|
-
const bundleBase64 = sync.bundleBase64;
|
|
1116
|
-
const bundleRef = sync.bundleRef;
|
|
1117
|
-
return withRepoMutationLock(
|
|
1118
|
-
{
|
|
1119
|
-
cwd: repoRoot,
|
|
1120
|
-
operation: "collabSync"
|
|
1121
|
-
},
|
|
1122
|
-
async ({ repoRoot: lockedRepoRoot, warnings }) => {
|
|
1123
|
-
await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
|
|
1124
|
-
operation: "`remix collab sync`",
|
|
1125
|
-
recoveryHint: "The repository changed after sync was prepared. Review the local changes and rerun `remix collab sync`."
|
|
1682
|
+
}
|
|
1683
|
+
async function processPendingFinalizeJob(params) {
|
|
1684
|
+
const claimed = await claimPendingFinalizeJob(params.jobId);
|
|
1685
|
+
if (!claimed) {
|
|
1686
|
+
const job = await readPendingFinalizeJob(params.jobId);
|
|
1687
|
+
if (job?.status === "processing") {
|
|
1688
|
+
throw new RemixError("Finalize job is already being processed.", { exitCode: 1 });
|
|
1689
|
+
}
|
|
1690
|
+
if (job?.status === "failed") {
|
|
1691
|
+
throw new RemixError("Finalize job failed permanently and will not retry automatically.", {
|
|
1692
|
+
exitCode: 1,
|
|
1693
|
+
hint: job.error ?? "Review the local finalize queue and record a fresh Remix turn if needed."
|
|
1126
1694
|
});
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1695
|
+
}
|
|
1696
|
+
throw new RemixError("Finalize job was not found.", { exitCode: 1 });
|
|
1697
|
+
}
|
|
1698
|
+
return processClaimedPendingFinalizeJob({ api: params.api, job: claimed.job, release: claimed.release });
|
|
1699
|
+
}
|
|
1700
|
+
async function enqueueCapturedFinalizeTurn(params) {
|
|
1701
|
+
return enqueuePendingFinalizeJob({
|
|
1702
|
+
status: "queued",
|
|
1703
|
+
repoRoot: params.repoRoot,
|
|
1704
|
+
repoFingerprint: params.repoFingerprint,
|
|
1705
|
+
currentAppId: params.currentAppId,
|
|
1706
|
+
laneId: params.laneId,
|
|
1707
|
+
threadId: params.threadId,
|
|
1708
|
+
branchName: params.branchName,
|
|
1709
|
+
prompt: params.prompt,
|
|
1710
|
+
assistantResponse: params.assistantResponse,
|
|
1711
|
+
baselineSnapshotId: params.baselineSnapshotId,
|
|
1712
|
+
baselineServerHeadHash: params.baselineServerHeadHash,
|
|
1713
|
+
currentSnapshotId: params.currentSnapshotId,
|
|
1714
|
+
idempotencyKey: params.idempotencyKey,
|
|
1715
|
+
error: null,
|
|
1716
|
+
retryCount: 0,
|
|
1717
|
+
lastAttemptAt: null,
|
|
1718
|
+
nextRetryAt: null,
|
|
1719
|
+
metadata: params.metadata ?? {}
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
async function drainPendingFinalizeQueue(params) {
|
|
1723
|
+
await prunePendingFinalizeJobs();
|
|
1724
|
+
const jobs = await listPendingFinalizeJobs();
|
|
1725
|
+
const results = [];
|
|
1726
|
+
for (const job of jobs) {
|
|
1727
|
+
const claimed = await claimPendingFinalizeJob(job.id);
|
|
1728
|
+
if (!claimed) continue;
|
|
1729
|
+
try {
|
|
1730
|
+
const result = await processClaimedPendingFinalizeJob({
|
|
1731
|
+
api: params.api,
|
|
1732
|
+
job: claimed.job,
|
|
1733
|
+
release: claimed.release
|
|
1134
1734
|
});
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
await fs2.writeFile(bundlePath, Buffer.from(bundleBase64, "base64"));
|
|
1139
|
-
await importGitBundle(lockedRepoRoot, bundlePath, bundleRef);
|
|
1140
|
-
await ensureCommitExists(lockedRepoRoot, sync.targetCommitHash);
|
|
1141
|
-
const localCommitHash = await fastForwardToCommit(lockedRepoRoot, sync.targetCommitHash);
|
|
1142
|
-
return {
|
|
1143
|
-
...previewResult,
|
|
1144
|
-
localCommitHash,
|
|
1145
|
-
applied: true,
|
|
1146
|
-
dryRun: false,
|
|
1147
|
-
...warnings.length > 0 ? { warnings } : {}
|
|
1148
|
-
};
|
|
1149
|
-
} finally {
|
|
1150
|
-
await fs2.rm(tempDir, { recursive: true, force: true });
|
|
1151
|
-
}
|
|
1735
|
+
results.push(result);
|
|
1736
|
+
await removePendingFinalizeJob(job.id);
|
|
1737
|
+
} catch {
|
|
1152
1738
|
}
|
|
1153
|
-
|
|
1739
|
+
}
|
|
1740
|
+
return results;
|
|
1154
1741
|
}
|
|
1155
1742
|
|
|
1156
|
-
// src/application/collab/
|
|
1157
|
-
function
|
|
1158
|
-
if (
|
|
1743
|
+
// src/application/collab/collabFinalizeTurn.ts
|
|
1744
|
+
function collectWarnings(value) {
|
|
1745
|
+
if (!Array.isArray(value)) return [];
|
|
1746
|
+
return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
1747
|
+
}
|
|
1748
|
+
var FINALIZE_QUEUED_WARNING = "Queued only: the local Remix turn was captured, but no remote change step or collab turn exists yet. Drain or await finalize before merge-related flows.";
|
|
1749
|
+
async function collabFinalizeTurn(params) {
|
|
1750
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
1751
|
+
const binding = await ensureActiveLaneBinding({
|
|
1752
|
+
repoRoot,
|
|
1753
|
+
api: params.api,
|
|
1754
|
+
operation: "`remix collab finalize-turn`"
|
|
1755
|
+
});
|
|
1756
|
+
if (!binding) {
|
|
1159
1757
|
throw new RemixError("Repository is not bound to Remix.", {
|
|
1160
1758
|
exitCode: 2,
|
|
1161
|
-
hint:
|
|
1759
|
+
hint: "Run `remix collab init` first."
|
|
1162
1760
|
});
|
|
1163
1761
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1762
|
+
const prompt = params.prompt.trim();
|
|
1763
|
+
const assistantResponse = params.assistantResponse.trim();
|
|
1764
|
+
if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
|
|
1765
|
+
if (!assistantResponse) throw new RemixError("Assistant response is required.", { exitCode: 2 });
|
|
1766
|
+
if (params.diff?.trim()) {
|
|
1767
|
+
throw new RemixError("External diff submission is no longer supported for `finalize_turn`.", {
|
|
1166
1768
|
exitCode: 2,
|
|
1167
|
-
hint:
|
|
1769
|
+
hint: "Finalize turns now capture the real workspace boundary from the local snapshot store."
|
|
1168
1770
|
});
|
|
1169
1771
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1772
|
+
const detected = await collabDetectRepoState({
|
|
1773
|
+
api: params.api,
|
|
1774
|
+
cwd: repoRoot,
|
|
1775
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
1776
|
+
});
|
|
1777
|
+
if (detected.status === "not_bound") {
|
|
1778
|
+
throw new RemixError("Repository is not bound to Remix.", { exitCode: 2, hint: detected.hint });
|
|
1779
|
+
}
|
|
1780
|
+
if (detected.status === "branch_binding_missing" || detected.status === "family_ambiguous") {
|
|
1781
|
+
throw new RemixError(detected.hint || "Current branch is not ready for Remix recording.", { exitCode: 2, hint: detected.hint });
|
|
1175
1782
|
}
|
|
1176
|
-
if (
|
|
1177
|
-
throw new RemixError(
|
|
1783
|
+
if (detected.status === "metadata_conflict" || detected.status === "branch_mismatch") {
|
|
1784
|
+
throw new RemixError("Repository must be realigned before finalizing the turn.", {
|
|
1178
1785
|
exitCode: 2,
|
|
1179
|
-
hint:
|
|
1786
|
+
hint: detected.hint
|
|
1180
1787
|
});
|
|
1181
1788
|
}
|
|
1182
|
-
if (
|
|
1183
|
-
throw new RemixError("Failed to
|
|
1789
|
+
if (detected.status === "missing_head" || detected.status === "remote_error") {
|
|
1790
|
+
throw new RemixError(detected.hint || "Failed to determine the current repo state.", {
|
|
1184
1791
|
exitCode: 1,
|
|
1185
|
-
hint:
|
|
1792
|
+
hint: detected.hint
|
|
1186
1793
|
});
|
|
1187
1794
|
}
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
allowBranchMismatch: false,
|
|
1193
|
-
operation: "`remix collab add`"
|
|
1795
|
+
if (detected.repoState === "server_only_changed") {
|
|
1796
|
+
throw new RemixError("Server changes must be pulled locally before finalizing this turn.", {
|
|
1797
|
+
exitCode: 2,
|
|
1798
|
+
hint: detected.hint
|
|
1194
1799
|
});
|
|
1195
1800
|
}
|
|
1196
|
-
if (
|
|
1197
|
-
throw new RemixError("
|
|
1801
|
+
if (detected.repoState === "external_local_base_changed") {
|
|
1802
|
+
throw new RemixError("The local checkout must be re-anchored before finalizing this turn.", {
|
|
1198
1803
|
exitCode: 2,
|
|
1199
|
-
hint:
|
|
1804
|
+
hint: detected.hint
|
|
1200
1805
|
});
|
|
1201
1806
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1807
|
+
const baseline = await readLocalBaseline({
|
|
1808
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1809
|
+
laneId: binding.laneId,
|
|
1810
|
+
repoRoot
|
|
1811
|
+
});
|
|
1812
|
+
if (!baseline) {
|
|
1813
|
+
throw new RemixError("Local Remix baseline is missing for this lane.", {
|
|
1204
1814
|
exitCode: 2,
|
|
1205
|
-
hint:
|
|
1815
|
+
hint: "Run `remix collab re-anchor` to create a fresh baseline."
|
|
1206
1816
|
});
|
|
1207
1817
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1818
|
+
const snapshot = await captureLocalSnapshot({
|
|
1819
|
+
repoRoot,
|
|
1820
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1821
|
+
laneId: binding.laneId,
|
|
1822
|
+
branchName: binding.branchName
|
|
1823
|
+
});
|
|
1824
|
+
const mode = snapshot.snapshotHash === baseline.lastSnapshotHash ? "no_diff_turn" : "changed_turn";
|
|
1825
|
+
const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
|
|
1826
|
+
kind: "collab_finalize_turn_boundary_v1",
|
|
1827
|
+
appId: binding.currentAppId,
|
|
1828
|
+
laneId: binding.laneId,
|
|
1829
|
+
baselineSnapshotId: baseline.lastSnapshotId,
|
|
1830
|
+
baselineServerHeadHash: baseline.lastServerHeadHash,
|
|
1831
|
+
currentSnapshotId: snapshot.id,
|
|
1832
|
+
currentSnapshotHash: snapshot.snapshotHash,
|
|
1833
|
+
repoState: detected.repoState,
|
|
1834
|
+
prompt,
|
|
1835
|
+
assistantResponse
|
|
1836
|
+
});
|
|
1837
|
+
const job = await enqueueCapturedFinalizeTurn({
|
|
1212
1838
|
repoRoot,
|
|
1839
|
+
repoFingerprint: binding.repoFingerprint,
|
|
1840
|
+
currentAppId: binding.currentAppId,
|
|
1841
|
+
laneId: binding.laneId,
|
|
1842
|
+
threadId: binding.threadId,
|
|
1843
|
+
branchName: binding.branchName,
|
|
1844
|
+
prompt,
|
|
1845
|
+
assistantResponse,
|
|
1846
|
+
baselineSnapshotId: baseline.lastSnapshotId,
|
|
1847
|
+
baselineServerHeadHash: baseline.lastServerHeadHash,
|
|
1848
|
+
currentSnapshotId: snapshot.id,
|
|
1849
|
+
idempotencyKey,
|
|
1850
|
+
metadata: {
|
|
1851
|
+
remoteUrl: binding.remoteUrl,
|
|
1852
|
+
defaultBranch: binding.defaultBranch,
|
|
1853
|
+
actor: params.actor ?? null,
|
|
1854
|
+
repoState: detected.repoState
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
return {
|
|
1858
|
+
mode,
|
|
1859
|
+
idempotencyKey,
|
|
1860
|
+
queued: true,
|
|
1861
|
+
jobId: job.id,
|
|
1862
|
+
repoState: detected.repoState,
|
|
1863
|
+
changeStep: null,
|
|
1864
|
+
collabTurn: null,
|
|
1865
|
+
autoSync: null,
|
|
1866
|
+
warnings: [FINALIZE_QUEUED_WARNING, ...collectWarnings(detected.warnings)]
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/application/collab/recordingPreflight.ts
|
|
1871
|
+
async function collabRecordingPreflight(params) {
|
|
1872
|
+
const detected = await collabDetectRepoState({
|
|
1213
1873
|
api: params.api,
|
|
1214
|
-
|
|
1874
|
+
cwd: params.cwd,
|
|
1875
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
1215
1876
|
});
|
|
1216
|
-
if (
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1877
|
+
if (detected.status === "not_git_repo") {
|
|
1878
|
+
return {
|
|
1879
|
+
status: "not_git_repo",
|
|
1880
|
+
repoState: detected.repoState,
|
|
1881
|
+
repoRoot: null,
|
|
1882
|
+
appId: null,
|
|
1883
|
+
currentBranch: null,
|
|
1884
|
+
branchName: null,
|
|
1885
|
+
headCommitHash: null,
|
|
1886
|
+
worktreeClean: false,
|
|
1887
|
+
syncStatus: null,
|
|
1888
|
+
syncTargetCommitHash: null,
|
|
1889
|
+
syncTargetCommitId: null,
|
|
1890
|
+
reconcileTargetHeadCommitHash: null,
|
|
1891
|
+
reconcileTargetHeadCommitId: null,
|
|
1892
|
+
warnings: detected.warnings,
|
|
1893
|
+
hint: detected.hint
|
|
1894
|
+
};
|
|
1221
1895
|
}
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
const detail = formatCliErrorDetail(err);
|
|
1289
|
-
const hint = [
|
|
1290
|
-
detail,
|
|
1291
|
-
`The preserved local diff is available at: ${preserved.preservedDiffPath}`
|
|
1292
|
-
].filter(Boolean).join("\n\n");
|
|
1293
|
-
throw new RemixError("Failed to sync the stale repository before submitting the change step.", {
|
|
1294
|
-
exitCode: err instanceof RemixError ? err.exitCode : 1,
|
|
1295
|
-
hint
|
|
1296
|
-
});
|
|
1297
|
-
}
|
|
1298
|
-
headCommitHash = await getHeadCommitHash(repoRoot);
|
|
1299
|
-
if (!headCommitHash) {
|
|
1300
|
-
throw new RemixError("Failed to resolve local HEAD after syncing.", { exitCode: 1 });
|
|
1301
|
-
}
|
|
1302
|
-
const deterministicReapply = await reapplyPreservedWorkspaceChanges(repoRoot, preserved);
|
|
1303
|
-
if (deterministicReapply.status === "failed") {
|
|
1304
|
-
const hint = [
|
|
1305
|
-
deterministicReapply.detail,
|
|
1306
|
-
`The preserved local diff is available at: ${preserved.preservedDiffPath}`
|
|
1307
|
-
].filter(Boolean).join("\n\n");
|
|
1308
|
-
throw new RemixError("Failed to restore preserved local changes after syncing.", {
|
|
1309
|
-
exitCode: 1,
|
|
1310
|
-
hint
|
|
1311
|
-
});
|
|
1312
|
-
}
|
|
1313
|
-
if (deterministicReapply.status === "conflict") {
|
|
1314
|
-
try {
|
|
1315
|
-
const replayResp = await params.api.startChangeStepReplay(binding.currentAppId, {
|
|
1316
|
-
prompt,
|
|
1317
|
-
assistantResponse: assistantResponse ?? void 0,
|
|
1318
|
-
diff: await fs3.readFile(preserved.preservedDiffPath, "utf8"),
|
|
1319
|
-
baseCommitHash: preserved.baseHeadCommitHash,
|
|
1320
|
-
targetHeadCommitHash: headCommitHash,
|
|
1321
|
-
expectedPaths: preserved.stagePlan.expectedPaths,
|
|
1322
|
-
actor: params.actor,
|
|
1323
|
-
workspaceMetadata: {
|
|
1324
|
-
branch,
|
|
1325
|
-
repoRoot,
|
|
1326
|
-
remoteUrl: binding.remoteUrl,
|
|
1327
|
-
defaultBranch: binding.defaultBranch
|
|
1328
|
-
},
|
|
1329
|
-
idempotencyKey: buildDeterministicIdempotencyKey({
|
|
1330
|
-
appId: binding.currentAppId,
|
|
1331
|
-
baseCommitHash: preserved.baseHeadCommitHash,
|
|
1332
|
-
targetHeadCommitHash: headCommitHash,
|
|
1333
|
-
prompt,
|
|
1334
|
-
assistantResponse,
|
|
1335
|
-
preservedDiffSha256: preserved.preservedDiffSha256
|
|
1336
|
-
})
|
|
1337
|
-
});
|
|
1338
|
-
const startedReplay = unwrapResponseObject(replayResp, "change step replay");
|
|
1339
|
-
const replay = await pollChangeStepReplay(params.api, binding.currentAppId, String(startedReplay.id));
|
|
1340
|
-
const replayDiffResp = await params.api.getChangeStepReplayDiff(binding.currentAppId, String(replay.id));
|
|
1341
|
-
const replayDiff = unwrapResponseObject(replayDiffResp, "change step replay diff");
|
|
1342
|
-
const { backupPath: backupPath2, diffSha256 } = await writeTempUnifiedDiffBackup(replayDiff.diff, "remix-add-ai-replay");
|
|
1343
|
-
const replayApply = await reapplyPreservedWorkspaceChanges(repoRoot, {
|
|
1344
|
-
baseHeadCommitHash: headCommitHash,
|
|
1345
|
-
preservedDiffPath: backupPath2,
|
|
1346
|
-
preservedDiffSha256: diffSha256,
|
|
1347
|
-
includedUntrackedPaths: [],
|
|
1348
|
-
stagePlan: preserved.stagePlan
|
|
1349
|
-
});
|
|
1350
|
-
if (replayApply.status !== "clean") {
|
|
1351
|
-
const hint = [
|
|
1352
|
-
replayApply.detail,
|
|
1353
|
-
`The preserved local diff is available at: ${preserved.preservedDiffPath}`,
|
|
1354
|
-
`The AI-replayed diff is available at: ${backupPath2}`
|
|
1355
|
-
].filter(Boolean).join("\n\n");
|
|
1356
|
-
throw new RemixError("AI-assisted stale-work replay produced a diff that could not be applied locally.", {
|
|
1357
|
-
exitCode: 1,
|
|
1358
|
-
hint
|
|
1359
|
-
});
|
|
1360
|
-
}
|
|
1361
|
-
} catch (err) {
|
|
1362
|
-
const detail = formatCliErrorDetail(err);
|
|
1363
|
-
const hint = [
|
|
1364
|
-
detail,
|
|
1365
|
-
`The preserved local diff is available at: ${preserved.preservedDiffPath}`,
|
|
1366
|
-
"Resolve the local conflict manually if needed, then rerun `remix collab add`."
|
|
1367
|
-
].filter(Boolean).join("\n\n");
|
|
1368
|
-
throw new RemixError("AI-assisted stale-work replay could not complete safely.", {
|
|
1369
|
-
exitCode: err instanceof RemixError ? err.exitCode : 1,
|
|
1370
|
-
hint
|
|
1371
|
-
});
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
const workspaceSnapshot = diffSource === "external" ? null : await getWorkspaceSnapshot(repoRoot);
|
|
1377
|
-
const submissionSnapshot = diffSource === "worktree" ? await captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true }) : null;
|
|
1378
|
-
const diff = params.diff ?? workspaceSnapshot?.diff ?? "";
|
|
1379
|
-
if (!diff.trim()) {
|
|
1380
|
-
throw new RemixError("Diff is empty.", {
|
|
1381
|
-
exitCode: 2,
|
|
1382
|
-
hint: "Make changes first, or pass `--diff-file`/`--diff-stdin`."
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
if (diffSource === "external") {
|
|
1386
|
-
const validation = await validateUnifiedDiff(repoRoot, diff);
|
|
1387
|
-
if (!validation.ok) {
|
|
1388
|
-
const actionHint = validation.kind === "malformed_patch" ? "The provided external diff is malformed. Recreate it with `git diff --binary --no-ext-diff`, avoid hand-editing patch hunks, and ensure the patch ends with a trailing newline." : validation.kind === "apply_conflict" ? "The external diff is valid patch syntax, but it does not apply cleanly to the current local HEAD. Sync or update the repo and regenerate the diff against the latest base." : "Git could not validate the provided external diff against the current repository state.";
|
|
1389
|
-
const hint = [validation.detail, actionHint].filter(Boolean).join("\n\n");
|
|
1390
|
-
throw new RemixError("External diff validation failed.", {
|
|
1391
|
-
exitCode: validation.kind === "malformed_patch" ? 2 : 1,
|
|
1392
|
-
hint
|
|
1393
|
-
});
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
headCommitHash = await getHeadCommitHash(repoRoot);
|
|
1397
|
-
if (!headCommitHash) {
|
|
1398
|
-
throw new RemixError("Failed to resolve local HEAD before creating the change step.", { exitCode: 1 });
|
|
1399
|
-
}
|
|
1400
|
-
const stats = summarizeUnifiedDiff(diff);
|
|
1401
|
-
const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
|
|
1402
|
-
appId: binding.currentAppId,
|
|
1403
|
-
upstreamAppId: binding.upstreamAppId,
|
|
1404
|
-
headCommitHash,
|
|
1405
|
-
prompt,
|
|
1406
|
-
assistantResponse,
|
|
1407
|
-
diff
|
|
1408
|
-
});
|
|
1409
|
-
const resp = await params.api.createChangeStep(binding.currentAppId, {
|
|
1410
|
-
threadId: binding.threadId ?? void 0,
|
|
1411
|
-
collabLaneId: binding.laneId ?? void 0,
|
|
1412
|
-
prompt,
|
|
1413
|
-
assistantResponse: assistantResponse ?? void 0,
|
|
1414
|
-
diff,
|
|
1415
|
-
baseCommitHash: headCommitHash,
|
|
1416
|
-
headCommitHash,
|
|
1417
|
-
changedFilesCount: stats.changedFilesCount,
|
|
1418
|
-
insertions: stats.insertions,
|
|
1419
|
-
deletions: stats.deletions,
|
|
1420
|
-
actor: params.actor,
|
|
1421
|
-
workspaceMetadata: {
|
|
1422
|
-
branch,
|
|
1423
|
-
repoRoot,
|
|
1424
|
-
remoteUrl: binding.remoteUrl,
|
|
1425
|
-
defaultBranch: binding.defaultBranch
|
|
1426
|
-
},
|
|
1427
|
-
idempotencyKey
|
|
1428
|
-
});
|
|
1429
|
-
const created = unwrapResponseObject(resp, "change step");
|
|
1430
|
-
const step = await pollChangeStep(params.api, binding.currentAppId, String(created.id));
|
|
1431
|
-
const canAutoSyncLocally = autoSyncEnabled && diffSource === "worktree";
|
|
1432
|
-
if (!autoSyncEnabled || !canAutoSyncLocally) {
|
|
1433
|
-
return attachWarnings(step, lockWarnings);
|
|
1896
|
+
const syncTargetCommitHash = detected.currentServerHeadHash;
|
|
1897
|
+
const syncTargetCommitId = detected.currentServerHeadCommitId;
|
|
1898
|
+
const base = {
|
|
1899
|
+
repoState: detected.repoState,
|
|
1900
|
+
repoRoot: detected.repoRoot,
|
|
1901
|
+
appId: detected.binding?.currentAppId ?? null,
|
|
1902
|
+
currentBranch: detected.currentBranch,
|
|
1903
|
+
branchName: detected.branchName,
|
|
1904
|
+
headCommitHash: detected.localCommitHash,
|
|
1905
|
+
worktreeClean: detected.worktreeClean,
|
|
1906
|
+
syncStatus: detected.repoState === "server_only_changed" ? "delta_ready" : detected.status === "metadata_conflict" ? "conflict_risk" : null,
|
|
1907
|
+
syncTargetCommitHash,
|
|
1908
|
+
syncTargetCommitId,
|
|
1909
|
+
reconcileTargetHeadCommitHash: detected.currentServerHeadHash,
|
|
1910
|
+
reconcileTargetHeadCommitId: detected.currentServerHeadCommitId,
|
|
1911
|
+
warnings: detected.warnings,
|
|
1912
|
+
hint: detected.hint
|
|
1913
|
+
};
|
|
1914
|
+
if (detected.status === "not_bound") return { status: "not_bound", ...base };
|
|
1915
|
+
if (detected.status === "branch_binding_missing") return { status: "branch_binding_missing", ...base };
|
|
1916
|
+
if (detected.status === "family_ambiguous") return { status: "family_ambiguous", ...base };
|
|
1917
|
+
if (detected.status === "metadata_conflict") return { status: "metadata_conflict", ...base };
|
|
1918
|
+
if (detected.status === "missing_head") return { status: "missing_head", ...base };
|
|
1919
|
+
if (detected.status === "branch_mismatch") return { status: "branch_mismatch", ...base };
|
|
1920
|
+
if (detected.repoState === "server_only_changed") return { status: "pull_required", ...base };
|
|
1921
|
+
if (detected.repoState === "both_changed") return { status: "reconcile_required", ...base };
|
|
1922
|
+
if (detected.repoState === "external_local_base_changed") return { status: "re_anchor_required", ...base };
|
|
1923
|
+
return { status: "ready", ...base };
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// src/infrastructure/locking/repoMutationLock.ts
|
|
1927
|
+
import fs4 from "fs/promises";
|
|
1928
|
+
import os3 from "os";
|
|
1929
|
+
import path5 from "path";
|
|
1930
|
+
var DEFAULT_ACQUIRE_TIMEOUT_MS = 15e3;
|
|
1931
|
+
var DEFAULT_STALE_MS = 45e3;
|
|
1932
|
+
var DEFAULT_HEARTBEAT_MS = 5e3;
|
|
1933
|
+
var RETRY_DELAY_MS = 250;
|
|
1934
|
+
var heldLocks = /* @__PURE__ */ new Map();
|
|
1935
|
+
function sleep2(ms) {
|
|
1936
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1937
|
+
}
|
|
1938
|
+
function createOwner(params) {
|
|
1939
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1940
|
+
return {
|
|
1941
|
+
operation: params.operation,
|
|
1942
|
+
repoRoot: params.repoRoot,
|
|
1943
|
+
pid: process.pid,
|
|
1944
|
+
hostname: os3.hostname(),
|
|
1945
|
+
startedAt: now,
|
|
1946
|
+
heartbeatAt: now,
|
|
1947
|
+
version: process.version,
|
|
1948
|
+
requestId: params.requestId?.trim() || null
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
async function writeOwnerMetadata(ownerPath, owner) {
|
|
1952
|
+
await fs4.writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}
|
|
1953
|
+
`, "utf8");
|
|
1954
|
+
}
|
|
1955
|
+
async function readOwnerMetadata(ownerPath) {
|
|
1956
|
+
try {
|
|
1957
|
+
const raw = await fs4.readFile(ownerPath, "utf8");
|
|
1958
|
+
const parsed = JSON.parse(raw);
|
|
1959
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
1960
|
+
if (!parsed.operation || !parsed.repoRoot || typeof parsed.pid !== "number" || !parsed.startedAt || !parsed.heartbeatAt) {
|
|
1961
|
+
return null;
|
|
1434
1962
|
}
|
|
1435
|
-
|
|
1963
|
+
return {
|
|
1964
|
+
operation: parsed.operation,
|
|
1965
|
+
repoRoot: parsed.repoRoot,
|
|
1966
|
+
pid: parsed.pid,
|
|
1967
|
+
hostname: typeof parsed.hostname === "string" ? parsed.hostname : "unknown",
|
|
1968
|
+
startedAt: parsed.startedAt,
|
|
1969
|
+
heartbeatAt: parsed.heartbeatAt,
|
|
1970
|
+
version: typeof parsed.version === "string" ? parsed.version : "unknown",
|
|
1971
|
+
requestId: typeof parsed.requestId === "string" ? parsed.requestId : null
|
|
1972
|
+
};
|
|
1973
|
+
} catch {
|
|
1974
|
+
return null;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
async function isProcessAlive(owner) {
|
|
1978
|
+
if (!owner) return null;
|
|
1979
|
+
if (owner.hostname !== os3.hostname()) return null;
|
|
1980
|
+
try {
|
|
1981
|
+
process.kill(owner.pid, 0);
|
|
1982
|
+
return true;
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
if (error?.code === "EPERM") return true;
|
|
1985
|
+
if (error?.code === "ESRCH") return false;
|
|
1986
|
+
return null;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
async function getLastKnownUpdateMs(lockDir, ownerPath, owner) {
|
|
1990
|
+
const heartbeatMs = owner ? Date.parse(owner.heartbeatAt) : Number.NaN;
|
|
1991
|
+
if (Number.isFinite(heartbeatMs)) return heartbeatMs;
|
|
1992
|
+
const startedMs = owner ? Date.parse(owner.startedAt) : Number.NaN;
|
|
1993
|
+
if (Number.isFinite(startedMs)) return startedMs;
|
|
1994
|
+
const stat = await fs4.stat(ownerPath).catch(() => null);
|
|
1995
|
+
if (stat) return stat.mtimeMs;
|
|
1996
|
+
const dirStat = await fs4.stat(lockDir).catch(() => null);
|
|
1997
|
+
if (dirStat) return dirStat.mtimeMs;
|
|
1998
|
+
return 0;
|
|
1999
|
+
}
|
|
2000
|
+
async function ensureLockDir(lockDir) {
|
|
2001
|
+
await fs4.mkdir(path5.dirname(lockDir), { recursive: true });
|
|
2002
|
+
}
|
|
2003
|
+
async function tryAcquireLock(lockDir, ownerPath, owner) {
|
|
2004
|
+
try {
|
|
2005
|
+
await ensureLockDir(lockDir);
|
|
2006
|
+
await fs4.mkdir(lockDir);
|
|
1436
2007
|
try {
|
|
1437
|
-
await
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
recoveryHint: "The repository changed after the change step was submitted. Review the local changes, inspect the preserved diff if needed, and rerun `remix collab sync` manually."
|
|
1442
|
-
});
|
|
1443
|
-
}
|
|
1444
|
-
await discardTrackedChanges(repoRoot, "`remix collab add`");
|
|
1445
|
-
await discardCapturedUntrackedChanges(repoRoot, workspaceSnapshot?.includedUntrackedPaths ?? []);
|
|
1446
|
-
await collabSync({
|
|
1447
|
-
api: params.api,
|
|
1448
|
-
cwd: repoRoot,
|
|
1449
|
-
dryRun: false,
|
|
1450
|
-
allowBranchMismatch: params.allowBranchMismatch
|
|
1451
|
-
});
|
|
1452
|
-
await fs3.rm(path3.dirname(backupPath), { recursive: true, force: true }).catch(() => void 0);
|
|
1453
|
-
} catch (err) {
|
|
1454
|
-
const detail = formatCliErrorDetail(err);
|
|
1455
|
-
const hint = [
|
|
1456
|
-
detail,
|
|
1457
|
-
`The submitted diff backup was preserved at: ${backupPath}`,
|
|
1458
|
-
"The change step already succeeded remotely. Inspect or reapply that diff manually if needed, then run `remix collab sync`."
|
|
1459
|
-
].filter(Boolean).join("\n\n");
|
|
1460
|
-
throw new RemixError("Change step succeeded remotely, but automatic local sync failed.", {
|
|
1461
|
-
exitCode: err instanceof RemixError ? err.exitCode : 1,
|
|
1462
|
-
hint
|
|
1463
|
-
});
|
|
2008
|
+
await writeOwnerMetadata(ownerPath, owner);
|
|
2009
|
+
} catch (error) {
|
|
2010
|
+
await fs4.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
2011
|
+
throw error;
|
|
1464
2012
|
}
|
|
1465
|
-
return
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
{
|
|
1470
|
-
cwd: repoRoot,
|
|
1471
|
-
operation: "collabAdd"
|
|
1472
|
-
},
|
|
1473
|
-
async ({ warnings }) => run(warnings)
|
|
1474
|
-
);
|
|
2013
|
+
return true;
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
if (error?.code === "EEXIST") return false;
|
|
2016
|
+
throw error;
|
|
1475
2017
|
}
|
|
1476
|
-
return run();
|
|
1477
2018
|
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
2019
|
+
function formatLockHint(params) {
|
|
2020
|
+
const lines = [
|
|
2021
|
+
params.observedHeldLock ? `Observed lock state: ${REMIX_ERROR_CODES.REPO_LOCK_HELD}.` : null,
|
|
2022
|
+
params.owner ? `Active operation: ${params.owner.operation}` : "Active operation: unknown",
|
|
2023
|
+
params.owner ? `Repo root: ${params.owner.repoRoot}` : null,
|
|
2024
|
+
params.owner ? `Owner: pid=${params.owner.pid} host=${params.owner.hostname}` : null,
|
|
2025
|
+
params.owner ? `Started at: ${params.owner.startedAt}` : null,
|
|
2026
|
+
params.owner ? `Heartbeat at: ${params.owner.heartbeatAt}` : null,
|
|
2027
|
+
`Waited ${params.waitedMs}ms for the repo mutation lock.`,
|
|
2028
|
+
`Stale lock threshold: ${params.staleMs}ms.`,
|
|
2029
|
+
"Retry after the active operation finishes. If the process crashed, wait for stale lock recovery or remove the stale lock manually if necessary."
|
|
2030
|
+
];
|
|
2031
|
+
return lines.filter(Boolean).join("\n");
|
|
2032
|
+
}
|
|
2033
|
+
function formatOwnerSummary(owner) {
|
|
2034
|
+
if (!owner) {
|
|
2035
|
+
return "unknown owner";
|
|
1492
2036
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
2037
|
+
return `operation=${owner.operation} pid=${owner.pid} host=${owner.hostname} startedAt=${owner.startedAt} heartbeatAt=${owner.heartbeatAt}`;
|
|
2038
|
+
}
|
|
2039
|
+
function buildStaleRecoveryNotice(owner) {
|
|
2040
|
+
return {
|
|
2041
|
+
code: REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED,
|
|
2042
|
+
owner,
|
|
2043
|
+
message: `[${REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED}] Recovered a stale Remix repo mutation lock (${formatOwnerSummary(owner)}).`
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
async function acquirePhysicalLock(lockDir, ownerPath, owner, options) {
|
|
2047
|
+
const startedAt = Date.now();
|
|
2048
|
+
const notices = [];
|
|
2049
|
+
let observedHeldLock = false;
|
|
2050
|
+
while (Date.now() - startedAt < options.acquireTimeoutMs) {
|
|
2051
|
+
if (await tryAcquireLock(lockDir, ownerPath, owner)) return notices;
|
|
2052
|
+
const currentOwner2 = await readOwnerMetadata(ownerPath);
|
|
2053
|
+
observedHeldLock = true;
|
|
2054
|
+
const lastUpdateMs = await getLastKnownUpdateMs(lockDir, ownerPath, currentOwner2);
|
|
2055
|
+
const ageMs = Math.max(0, Date.now() - lastUpdateMs);
|
|
2056
|
+
const alive = await isProcessAlive(currentOwner2);
|
|
2057
|
+
if (ageMs >= options.staleMs && alive !== true) {
|
|
2058
|
+
notices.push(buildStaleRecoveryNotice(currentOwner2));
|
|
2059
|
+
await fs4.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
await sleep2(RETRY_DELAY_MS);
|
|
1498
2063
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
2064
|
+
const currentOwner = await readOwnerMetadata(ownerPath);
|
|
2065
|
+
throw new RemixError("Repository is busy with another Remix mutation.", {
|
|
2066
|
+
code: REMIX_ERROR_CODES.REPO_LOCK_TIMEOUT,
|
|
2067
|
+
exitCode: 2,
|
|
2068
|
+
hint: formatLockHint({
|
|
2069
|
+
owner: currentOwner,
|
|
2070
|
+
waitedMs: Date.now() - startedAt,
|
|
2071
|
+
staleMs: options.staleMs,
|
|
2072
|
+
observedHeldLock
|
|
2073
|
+
})
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
function startHeartbeat(lockDir, ownerPath, owner, heartbeatMs) {
|
|
2077
|
+
return setInterval(() => {
|
|
2078
|
+
const nextOwner = {
|
|
2079
|
+
...owner,
|
|
2080
|
+
heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2081
|
+
};
|
|
2082
|
+
owner.heartbeatAt = nextOwner.heartbeatAt;
|
|
2083
|
+
void writeOwnerMetadata(ownerPath, nextOwner).catch(() => void 0);
|
|
2084
|
+
void fs4.utimes(lockDir, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
2085
|
+
}, heartbeatMs);
|
|
2086
|
+
}
|
|
2087
|
+
async function releaseReentrantLock(lockDir) {
|
|
2088
|
+
const held = heldLocks.get(lockDir);
|
|
2089
|
+
if (!held) return;
|
|
2090
|
+
held.count -= 1;
|
|
2091
|
+
if (held.count > 0) return;
|
|
2092
|
+
clearInterval(held.heartbeatTimer);
|
|
2093
|
+
heldLocks.delete(lockDir);
|
|
2094
|
+
await fs4.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
2095
|
+
}
|
|
2096
|
+
async function withRepoMutationLock(options, fn) {
|
|
2097
|
+
const repoRoot = await findGitRoot(options.cwd);
|
|
2098
|
+
const gitCommonDir = await getGitCommonDir(repoRoot);
|
|
2099
|
+
const lockDir = path5.join(gitCommonDir, "remix", "locks", "repo-mutation.lock");
|
|
2100
|
+
const owner = createOwner({
|
|
2101
|
+
operation: options.operation,
|
|
2102
|
+
repoRoot,
|
|
2103
|
+
requestId: options.requestId
|
|
2104
|
+
});
|
|
2105
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
2106
|
+
const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
2107
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
2108
|
+
const existing = heldLocks.get(lockDir);
|
|
2109
|
+
let notices = [];
|
|
2110
|
+
if (!existing) {
|
|
2111
|
+
notices = await acquirePhysicalLock(lockDir, path5.join(lockDir, "owner.json"), owner, {
|
|
2112
|
+
acquireTimeoutMs,
|
|
2113
|
+
staleMs
|
|
1503
2114
|
});
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
2115
|
+
const ownerPath = path5.join(lockDir, "owner.json");
|
|
2116
|
+
heldLocks.set(lockDir, {
|
|
2117
|
+
count: 1,
|
|
2118
|
+
lockDir,
|
|
2119
|
+
ownerPath,
|
|
2120
|
+
owner,
|
|
2121
|
+
heartbeatTimer: startHeartbeat(lockDir, ownerPath, owner, heartbeatMs)
|
|
1509
2122
|
});
|
|
2123
|
+
} else {
|
|
2124
|
+
existing.count += 1;
|
|
1510
2125
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
2126
|
+
try {
|
|
2127
|
+
return await fn({
|
|
2128
|
+
repoRoot,
|
|
2129
|
+
gitCommonDir,
|
|
2130
|
+
lockDir,
|
|
2131
|
+
notices,
|
|
2132
|
+
warnings: notices.map((notice) => notice.message)
|
|
1517
2133
|
});
|
|
2134
|
+
} finally {
|
|
2135
|
+
await releaseReentrantLock(lockDir);
|
|
1518
2136
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// src/application/collab/workspaceBaseline.ts
|
|
2140
|
+
async function ensureWorkspaceMatchesBaseline(params) {
|
|
2141
|
+
const baseline = await readLocalBaseline({
|
|
2142
|
+
repoFingerprint: params.repoFingerprint,
|
|
2143
|
+
laneId: params.laneId,
|
|
2144
|
+
repoRoot: params.repoRoot
|
|
2145
|
+
});
|
|
2146
|
+
if (!baseline?.lastSnapshotHash || !baseline.lastServerHeadHash) {
|
|
2147
|
+
throw new RemixError("Local Remix baseline is missing for this lane.", {
|
|
1521
2148
|
exitCode: 2,
|
|
1522
|
-
hint:
|
|
2149
|
+
hint: "Run `remix collab re-anchor` to create a fresh baseline before applying server changes."
|
|
1523
2150
|
});
|
|
1524
2151
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
2152
|
+
const inspection = await inspectLocalSnapshot({
|
|
2153
|
+
repoRoot: params.repoRoot,
|
|
2154
|
+
repoFingerprint: params.repoFingerprint,
|
|
2155
|
+
laneId: params.laneId,
|
|
2156
|
+
branchName: params.branchName,
|
|
2157
|
+
persistBlobs: false
|
|
2158
|
+
});
|
|
2159
|
+
if (inspection.snapshotHash !== baseline.lastSnapshotHash) {
|
|
2160
|
+
throw new RemixError(`Local boundary changes must be recorded or discarded before running ${params.operation}.`, {
|
|
1527
2161
|
exitCode: 2,
|
|
1528
|
-
hint:
|
|
2162
|
+
hint: "This checkout contains workspace changes that are not part of the current Remix baseline. Record them first, or restore the workspace back to the last agreed baseline before retrying."
|
|
1529
2163
|
});
|
|
1530
2164
|
}
|
|
2165
|
+
return { baseline, inspection };
|
|
1531
2166
|
}
|
|
1532
|
-
|
|
2167
|
+
|
|
2168
|
+
// src/application/collab/collabSync.ts
|
|
2169
|
+
async function collabSync(params) {
|
|
1533
2170
|
const repoRoot = await findGitRoot(params.cwd);
|
|
1534
2171
|
const binding = await ensureActiveLaneBinding({
|
|
1535
2172
|
repoRoot,
|
|
1536
2173
|
api: params.api,
|
|
1537
|
-
operation: "`remix collab
|
|
2174
|
+
operation: "`remix collab sync`"
|
|
1538
2175
|
});
|
|
1539
2176
|
if (!binding) {
|
|
1540
2177
|
throw new RemixError("Repository is not bound to Remix.", {
|
|
@@ -1542,167 +2179,159 @@ async function collabRecordTurn(params) {
|
|
|
1542
2179
|
hint: "Run `remix collab init` first."
|
|
1543
2180
|
});
|
|
1544
2181
|
}
|
|
1545
|
-
const
|
|
1546
|
-
const assistantResponse = params.assistantResponse.trim();
|
|
1547
|
-
if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
|
|
1548
|
-
if (!assistantResponse) throw new RemixError("Assistant response is required.", { exitCode: 2 });
|
|
1549
|
-
const preflight = await collabRecordingPreflight({
|
|
2182
|
+
const detected = await collabDetectRepoState({
|
|
1550
2183
|
api: params.api,
|
|
1551
2184
|
cwd: repoRoot,
|
|
1552
2185
|
allowBranchMismatch: params.allowBranchMismatch
|
|
1553
2186
|
});
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
throw new RemixError("Cannot record a no-diff turn while the worktree has local changes.", {
|
|
1557
|
-
exitCode: 2,
|
|
1558
|
-
hint: "Record the pending code changes as a Remix change step with `remix collab add`, or clean the worktree before retrying `remix collab record-turn`."
|
|
1559
|
-
});
|
|
2187
|
+
if (!detected.binding) {
|
|
2188
|
+
throw new RemixError("Repository is not bound to Remix.", { exitCode: 2, hint: detected.hint });
|
|
1560
2189
|
}
|
|
1561
|
-
if (
|
|
1562
|
-
await
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2190
|
+
if (detected.repoState === "idle") {
|
|
2191
|
+
const headCommitHash2 = await getHeadCommitHash(repoRoot);
|
|
2192
|
+
const branch2 = await requireCurrentBranch(repoRoot);
|
|
2193
|
+
if (!detected.currentServerHeadHash || !detected.currentServerHeadCommitId) {
|
|
2194
|
+
throw new RemixError("Failed to resolve the current Remix server head for this lane.", {
|
|
2195
|
+
exitCode: 1,
|
|
2196
|
+
hint: "Run `remix collab status` and retry after the repository is fully connected to Remix."
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
return {
|
|
2200
|
+
status: "up_to_date",
|
|
2201
|
+
branch: branch2,
|
|
2202
|
+
repoRoot,
|
|
2203
|
+
baseCommitHash: detected.currentServerHeadHash,
|
|
2204
|
+
targetCommitHash: detected.currentServerHeadHash,
|
|
2205
|
+
targetCommitId: detected.currentServerHeadCommitId,
|
|
2206
|
+
stats: { changedFilesCount: 0, insertions: 0, deletions: 0 },
|
|
2207
|
+
localCommitHash: headCommitHash2,
|
|
2208
|
+
applied: false,
|
|
2209
|
+
dryRun: params.dryRun
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
if (detected.repoState !== "server_only_changed") {
|
|
2213
|
+
throw new RemixError("A direct pull is only available when the server changed and the local workspace did not.", {
|
|
2214
|
+
exitCode: 2,
|
|
2215
|
+
hint: detected.hint
|
|
1567
2216
|
});
|
|
1568
2217
|
}
|
|
1569
|
-
const branch = await
|
|
1570
|
-
assertBoundBranchMatch({
|
|
1571
|
-
currentBranch: branch,
|
|
1572
|
-
branchName: binding.branchName,
|
|
1573
|
-
allowBranchMismatch: params.allowBranchMismatch,
|
|
1574
|
-
operation: "`remix collab record-turn`"
|
|
1575
|
-
});
|
|
2218
|
+
const branch = await requireCurrentBranch(repoRoot);
|
|
1576
2219
|
const headCommitHash = await getHeadCommitHash(repoRoot);
|
|
1577
|
-
const
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
workspaceMetadata: {
|
|
1591
|
-
branch,
|
|
2220
|
+
const repoSnapshot = await captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true });
|
|
2221
|
+
const bootstrapFromLocalHead = !detected.baseline.lastSnapshotHash || !detected.baseline.lastServerHeadHash;
|
|
2222
|
+
let baselineServerHeadHash;
|
|
2223
|
+
if (bootstrapFromLocalHead) {
|
|
2224
|
+
if (!headCommitHash) {
|
|
2225
|
+
throw new RemixError("Failed to resolve local HEAD commit for the initial sync bootstrap.", {
|
|
2226
|
+
exitCode: 1,
|
|
2227
|
+
hint: "Retry after Git HEAD is available, or run `remix collab re-anchor` if this checkout needs an explicit external-history re-anchor."
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
baselineServerHeadHash = headCommitHash;
|
|
2231
|
+
} else {
|
|
2232
|
+
const { baseline } = await ensureWorkspaceMatchesBaseline({
|
|
1592
2233
|
repoRoot,
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
2234
|
+
repoFingerprint: binding.repoFingerprint,
|
|
2235
|
+
laneId: binding.laneId,
|
|
2236
|
+
branchName: binding.branchName,
|
|
2237
|
+
operation: "`remix collab sync`"
|
|
2238
|
+
});
|
|
2239
|
+
if (!baseline.lastServerHeadHash) {
|
|
2240
|
+
throw new RemixError("Local Remix baseline is missing the last acknowledged server head.", {
|
|
2241
|
+
exitCode: 2,
|
|
2242
|
+
hint: "Run `remix collab re-anchor` to create a fresh baseline before pulling server changes."
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
baselineServerHeadHash = baseline.lastServerHeadHash;
|
|
2246
|
+
}
|
|
2247
|
+
const deltaResp = await params.api.getAppDelta(binding.currentAppId, {
|
|
2248
|
+
baseHeadHash: baselineServerHeadHash,
|
|
2249
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
2250
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
2251
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
1598
2252
|
});
|
|
1599
|
-
const
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
const repoRoot = await findGitRoot(params.cwd);
|
|
1610
|
-
const binding = await ensureActiveLaneBinding({
|
|
2253
|
+
const delta = unwrapResponseObject(deltaResp, "app delta");
|
|
2254
|
+
if (delta.status === "conflict_risk") {
|
|
2255
|
+
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
2256
|
+
exitCode: 2,
|
|
2257
|
+
hint: delta.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
const previewResult = {
|
|
2261
|
+
status: delta.status === "content_equivalent" ? "base_unknown" : delta.status,
|
|
2262
|
+
branch,
|
|
1611
2263
|
repoRoot,
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
2264
|
+
baseCommitHash: baselineServerHeadHash,
|
|
2265
|
+
targetCommitHash: delta.targetHeadHash,
|
|
2266
|
+
targetCommitId: delta.targetHeadCommitId,
|
|
2267
|
+
stats: delta.stats,
|
|
2268
|
+
localCommitHash: headCommitHash,
|
|
2269
|
+
applied: false,
|
|
2270
|
+
dryRun: params.dryRun
|
|
2271
|
+
};
|
|
2272
|
+
if (params.dryRun || delta.status === "up_to_date") {
|
|
2273
|
+
return previewResult;
|
|
2274
|
+
}
|
|
2275
|
+
if (delta.status === "base_unknown") {
|
|
2276
|
+
throw new RemixError("Direct pull is unavailable because Remix can no longer diff from the last acknowledged server head.", {
|
|
1617
2277
|
exitCode: 2,
|
|
1618
|
-
hint: "Run `remix collab
|
|
2278
|
+
hint: "Run `remix collab reconcile --dry-run` to inspect recovery options before retrying. If this checkout moved independently outside Remix, `remix collab re-anchor` may be required."
|
|
1619
2279
|
});
|
|
1620
2280
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
|
|
1624
|
-
if (!assistantResponse) throw new RemixError("Assistant response is required.", { exitCode: 2 });
|
|
1625
|
-
const diffSource = params.diffSource ?? (params.diff ? "external" : "worktree");
|
|
1626
|
-
const externalDiff = params.diff?.trim() ?? "";
|
|
1627
|
-
const workspaceDiff = diffSource === "worktree" ? await getWorkspaceDiff(repoRoot) : null;
|
|
1628
|
-
const hasChangedTurn = diffSource === "external" ? externalDiff.length > 0 : Boolean(workspaceDiff?.diff.trim());
|
|
1629
|
-
const currentHeadCommitHash = await getHeadCommitHash(repoRoot);
|
|
1630
|
-
const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
|
|
1631
|
-
kind: "collab_finalize_turn_v1",
|
|
1632
|
-
appId: binding.currentAppId,
|
|
1633
|
-
upstreamAppId: binding.upstreamAppId,
|
|
1634
|
-
headCommitHash: currentHeadCommitHash,
|
|
1635
|
-
modeHint: hasChangedTurn ? "changed_turn" : "no_diff_turn",
|
|
1636
|
-
prompt,
|
|
1637
|
-
assistantResponse,
|
|
1638
|
-
externalDiff: diffSource === "external" ? externalDiff : null
|
|
1639
|
-
});
|
|
1640
|
-
if (diffSource === "external" && !hasChangedTurn) {
|
|
1641
|
-
throw new RemixError("External diff is empty.", {
|
|
2281
|
+
if (delta.status !== "delta_ready") {
|
|
2282
|
+
throw new RemixError("Direct pull is not available for the current repository state.", {
|
|
1642
2283
|
exitCode: 2,
|
|
1643
|
-
hint: "
|
|
2284
|
+
hint: delta.warnings.join("\n") || "Run `remix collab status` to inspect the current alignment state."
|
|
1644
2285
|
});
|
|
1645
2286
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
const capturedUntrackedPathsCandidate = diffSource === "worktree" ? await listUntrackedFiles(repoRoot) : [];
|
|
1649
|
-
const changeStep = await collabAdd({
|
|
1650
|
-
api: params.api,
|
|
2287
|
+
return withRepoMutationLock(
|
|
2288
|
+
{
|
|
1651
2289
|
cwd: repoRoot,
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
});
|
|
1698
|
-
return {
|
|
1699
|
-
mode: "no_diff_turn",
|
|
1700
|
-
idempotencyKey,
|
|
1701
|
-
changeStep: null,
|
|
1702
|
-
collabTurn,
|
|
1703
|
-
autoSync: null,
|
|
1704
|
-
warnings: []
|
|
1705
|
-
};
|
|
2290
|
+
operation: "collabSync"
|
|
2291
|
+
},
|
|
2292
|
+
async ({ repoRoot: lockedRepoRoot, warnings }) => {
|
|
2293
|
+
if (bootstrapFromLocalHead) {
|
|
2294
|
+
await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
|
|
2295
|
+
operation: "`remix collab sync`",
|
|
2296
|
+
recoveryHint: "The repository changed before the first local Remix baseline could be created. Review the local changes and rerun `remix collab sync`."
|
|
2297
|
+
});
|
|
2298
|
+
} else {
|
|
2299
|
+
await ensureWorkspaceMatchesBaseline({
|
|
2300
|
+
repoRoot: lockedRepoRoot,
|
|
2301
|
+
repoFingerprint: binding.repoFingerprint,
|
|
2302
|
+
laneId: binding.laneId,
|
|
2303
|
+
branchName: binding.branchName,
|
|
2304
|
+
operation: "`remix collab sync`"
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
await applyUnifiedDiffToWorktree(lockedRepoRoot, delta.diff, "`remix collab pull`");
|
|
2308
|
+
const snapshot = await captureLocalSnapshot({
|
|
2309
|
+
repoRoot: lockedRepoRoot,
|
|
2310
|
+
repoFingerprint: binding.repoFingerprint,
|
|
2311
|
+
laneId: binding.laneId,
|
|
2312
|
+
branchName: binding.branchName
|
|
2313
|
+
});
|
|
2314
|
+
await writeLocalBaseline({
|
|
2315
|
+
repoRoot: lockedRepoRoot,
|
|
2316
|
+
repoFingerprint: binding.repoFingerprint,
|
|
2317
|
+
laneId: binding.laneId,
|
|
2318
|
+
currentAppId: binding.currentAppId,
|
|
2319
|
+
branchName: binding.branchName,
|
|
2320
|
+
lastSnapshotId: snapshot.id,
|
|
2321
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
2322
|
+
lastServerHeadHash: delta.targetHeadHash,
|
|
2323
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
2324
|
+
});
|
|
2325
|
+
return {
|
|
2326
|
+
...previewResult,
|
|
2327
|
+
status: "delta_ready",
|
|
2328
|
+
localCommitHash: snapshot.localCommitHash,
|
|
2329
|
+
applied: true,
|
|
2330
|
+
dryRun: false,
|
|
2331
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
);
|
|
1706
2335
|
}
|
|
1707
2336
|
|
|
1708
2337
|
// src/application/collab/collabApprove.ts
|
|
@@ -1788,39 +2417,39 @@ async function collabApprove(params) {
|
|
|
1788
2417
|
};
|
|
1789
2418
|
}
|
|
1790
2419
|
|
|
1791
|
-
// src/application/collab/checkoutWorkspace.ts
|
|
1792
|
-
import
|
|
1793
|
-
import
|
|
1794
|
-
import
|
|
2420
|
+
// src/application/collab/checkoutWorkspace.ts
|
|
2421
|
+
import fs5 from "fs/promises";
|
|
2422
|
+
import os4 from "os";
|
|
2423
|
+
import path6 from "path";
|
|
1795
2424
|
async function pathExists(targetPath) {
|
|
1796
2425
|
try {
|
|
1797
|
-
await
|
|
2426
|
+
await fs5.access(targetPath);
|
|
1798
2427
|
return true;
|
|
1799
2428
|
} catch {
|
|
1800
2429
|
return false;
|
|
1801
2430
|
}
|
|
1802
2431
|
}
|
|
1803
2432
|
async function statIsDirectory(targetPath) {
|
|
1804
|
-
const stats = await
|
|
2433
|
+
const stats = await fs5.stat(targetPath).catch(() => null);
|
|
1805
2434
|
return Boolean(stats?.isDirectory());
|
|
1806
2435
|
}
|
|
1807
2436
|
async function findContainingGitRoot(startPath) {
|
|
1808
|
-
let current =
|
|
2437
|
+
let current = path6.resolve(startPath);
|
|
1809
2438
|
while (true) {
|
|
1810
|
-
if (await pathExists(
|
|
1811
|
-
const parent =
|
|
2439
|
+
if (await pathExists(path6.join(current, ".git"))) return current;
|
|
2440
|
+
const parent = path6.dirname(current);
|
|
1812
2441
|
if (parent === current) return null;
|
|
1813
2442
|
current = parent;
|
|
1814
2443
|
}
|
|
1815
2444
|
}
|
|
1816
2445
|
function isSubpath(parentPath, candidatePath) {
|
|
1817
|
-
const relative =
|
|
1818
|
-
return relative === "" || !relative.startsWith("..") && !
|
|
2446
|
+
const relative = path6.relative(parentPath, candidatePath);
|
|
2447
|
+
return relative === "" || !relative.startsWith("..") && !path6.isAbsolute(relative);
|
|
1819
2448
|
}
|
|
1820
2449
|
async function resolveCheckoutDestination(params) {
|
|
1821
2450
|
if (params.outputDir?.trim()) {
|
|
1822
|
-
const preferredRepoRoot =
|
|
1823
|
-
const parentDir2 =
|
|
2451
|
+
const preferredRepoRoot = path6.resolve(params.outputDir.trim());
|
|
2452
|
+
const parentDir2 = path6.dirname(preferredRepoRoot);
|
|
1824
2453
|
if (!await statIsDirectory(parentDir2)) {
|
|
1825
2454
|
throw new RemixError("Remix output parent directory does not exist.", {
|
|
1826
2455
|
exitCode: 2,
|
|
@@ -1833,7 +2462,7 @@ async function resolveCheckoutDestination(params) {
|
|
|
1833
2462
|
explicitOutputDir: true
|
|
1834
2463
|
};
|
|
1835
2464
|
}
|
|
1836
|
-
const parentDir =
|
|
2465
|
+
const parentDir = path6.resolve(params.cwd);
|
|
1837
2466
|
if (!await statIsDirectory(parentDir)) {
|
|
1838
2467
|
throw new RemixError("Remix output parent directory does not exist.", {
|
|
1839
2468
|
exitCode: 2,
|
|
@@ -1841,7 +2470,7 @@ async function resolveCheckoutDestination(params) {
|
|
|
1841
2470
|
});
|
|
1842
2471
|
}
|
|
1843
2472
|
return {
|
|
1844
|
-
preferredRepoRoot:
|
|
2473
|
+
preferredRepoRoot: path6.join(parentDir, params.defaultDirName),
|
|
1845
2474
|
parentDir,
|
|
1846
2475
|
explicitOutputDir: false
|
|
1847
2476
|
};
|
|
@@ -1875,22 +2504,25 @@ async function materializeAppCheckout(params) {
|
|
|
1875
2504
|
explicitOutputDir: destination.explicitOutputDir
|
|
1876
2505
|
});
|
|
1877
2506
|
const repoRoot = destination.explicitOutputDir ? await reserveDirectory(destination.preferredRepoRoot) : await reserveAvailableDirPath(destination.preferredRepoRoot);
|
|
1878
|
-
const bundleTempDir = await
|
|
1879
|
-
const bundlePath =
|
|
2507
|
+
const bundleTempDir = await fs5.mkdtemp(path6.join(os4.tmpdir(), "remix-checkout-"));
|
|
2508
|
+
const bundlePath = path6.join(bundleTempDir, "repository.bundle");
|
|
1880
2509
|
try {
|
|
1881
2510
|
const bundle = await params.api.downloadAppBundle(params.appId);
|
|
1882
|
-
await
|
|
2511
|
+
await fs5.writeFile(bundlePath, bundle.data);
|
|
1883
2512
|
await cloneGitBundleToDirectory(bundlePath, repoRoot);
|
|
1884
2513
|
if (params.expectedBranchName?.trim()) {
|
|
1885
2514
|
await checkoutLocalBranch(repoRoot, params.expectedBranchName.trim());
|
|
1886
2515
|
}
|
|
2516
|
+
if (params.expectedRemoteUrl?.trim()) {
|
|
2517
|
+
await setRemoteOriginUrl(repoRoot, params.expectedRemoteUrl);
|
|
2518
|
+
}
|
|
1887
2519
|
await ensureGitInfoExcludeEntries(repoRoot, [".remix/"]);
|
|
1888
2520
|
} catch (err) {
|
|
1889
|
-
await
|
|
2521
|
+
await fs5.rm(repoRoot, { recursive: true, force: true }).catch(() => {
|
|
1890
2522
|
});
|
|
1891
2523
|
throw err;
|
|
1892
2524
|
} finally {
|
|
1893
|
-
await
|
|
2525
|
+
await fs5.rm(bundleTempDir, { recursive: true, force: true });
|
|
1894
2526
|
}
|
|
1895
2527
|
const branchName = await getCurrentBranch(repoRoot) ?? params.expectedBranchName?.trim() ?? null;
|
|
1896
2528
|
const remoteUrl = normalizeGitRemote(params.expectedRemoteUrl ?? await getRemoteOriginUrl(repoRoot));
|
|
@@ -1959,9 +2591,34 @@ async function collabCheckout(params) {
|
|
|
1959
2591
|
branchName: authoritativeLane?.branchName ?? branchName,
|
|
1960
2592
|
bindingMode: "lane"
|
|
1961
2593
|
});
|
|
2594
|
+
const currentAppId = authoritativeLane?.currentAppId ?? String(app.id);
|
|
2595
|
+
const repoFingerprintForBaseline = authoritativeLane?.repoFingerprint ?? repoFingerprint;
|
|
2596
|
+
const laneIdForBaseline = authoritativeLane?.laneId ?? laneId;
|
|
2597
|
+
const branchNameForBaseline = authoritativeLane?.branchName ?? branchName;
|
|
2598
|
+
const appHead = unwrapResponseObject(
|
|
2599
|
+
await params.api.getAppHead(currentAppId),
|
|
2600
|
+
"app head"
|
|
2601
|
+
);
|
|
2602
|
+
const snapshot = await captureLocalSnapshot({
|
|
2603
|
+
repoRoot: checkout.repoRoot,
|
|
2604
|
+
repoFingerprint: repoFingerprintForBaseline,
|
|
2605
|
+
laneId: laneIdForBaseline,
|
|
2606
|
+
branchName: branchNameForBaseline
|
|
2607
|
+
});
|
|
2608
|
+
await writeLocalBaseline({
|
|
2609
|
+
repoRoot: checkout.repoRoot,
|
|
2610
|
+
repoFingerprint: repoFingerprintForBaseline,
|
|
2611
|
+
laneId: laneIdForBaseline,
|
|
2612
|
+
currentAppId,
|
|
2613
|
+
branchName: branchNameForBaseline,
|
|
2614
|
+
lastSnapshotId: snapshot.id,
|
|
2615
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
2616
|
+
lastServerHeadHash: appHead.headCommitHash,
|
|
2617
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
2618
|
+
});
|
|
1962
2619
|
return {
|
|
1963
|
-
appId:
|
|
1964
|
-
dashboardUrl: buildDashboardAppUrl(
|
|
2620
|
+
appId: currentAppId,
|
|
2621
|
+
dashboardUrl: buildDashboardAppUrl(currentAppId),
|
|
1965
2622
|
projectId: authoritativeLane?.projectId ?? String(app.projectId),
|
|
1966
2623
|
upstreamAppId: authoritativeLane?.upstreamAppId ?? upstreamAppId,
|
|
1967
2624
|
bindingPath,
|
|
@@ -2140,16 +2797,16 @@ async function collabUpdateMemberRole(params) {
|
|
|
2140
2797
|
}
|
|
2141
2798
|
|
|
2142
2799
|
// src/application/collab/collabInit.ts
|
|
2143
|
-
import
|
|
2144
|
-
import
|
|
2800
|
+
import fs8 from "fs/promises";
|
|
2801
|
+
import path7 from "path";
|
|
2145
2802
|
|
|
2146
2803
|
// src/shared/hash.ts
|
|
2147
2804
|
import crypto from "crypto";
|
|
2148
|
-
import
|
|
2805
|
+
import fs6 from "fs";
|
|
2149
2806
|
async function sha256FileHex(filePath) {
|
|
2150
2807
|
const hash = crypto.createHash("sha256");
|
|
2151
2808
|
await new Promise((resolve, reject) => {
|
|
2152
|
-
const stream =
|
|
2809
|
+
const stream = fs6.createReadStream(filePath);
|
|
2153
2810
|
stream.on("data", (chunk) => hash.update(chunk));
|
|
2154
2811
|
stream.on("error", reject);
|
|
2155
2812
|
stream.on("end", () => resolve());
|
|
@@ -2158,15 +2815,15 @@ async function sha256FileHex(filePath) {
|
|
|
2158
2815
|
}
|
|
2159
2816
|
|
|
2160
2817
|
// src/shared/upload.ts
|
|
2161
|
-
import
|
|
2818
|
+
import fs7 from "fs";
|
|
2162
2819
|
import { PassThrough } from "stream";
|
|
2163
2820
|
async function uploadPresigned(params) {
|
|
2164
|
-
const stats = await
|
|
2821
|
+
const stats = await fs7.promises.stat(params.filePath).catch(() => null);
|
|
2165
2822
|
if (!stats || !stats.isFile()) {
|
|
2166
2823
|
throw new RemixError("Upload file not found.", { exitCode: 2 });
|
|
2167
2824
|
}
|
|
2168
2825
|
const totalBytes = stats.size;
|
|
2169
|
-
const fileStream =
|
|
2826
|
+
const fileStream = fs7.createReadStream(params.filePath);
|
|
2170
2827
|
const pass = new PassThrough();
|
|
2171
2828
|
let sentBytes = 0;
|
|
2172
2829
|
fileStream.on("data", (chunk) => {
|
|
@@ -2251,7 +2908,8 @@ async function resolveOrEnsureLaneBinding(params) {
|
|
|
2251
2908
|
lane = unwrapResponseObject(
|
|
2252
2909
|
await params.api.ensureProjectLaneBinding({
|
|
2253
2910
|
...resolvePayload,
|
|
2254
|
-
seedAppId: params.seedAppId ?? void 0
|
|
2911
|
+
seedAppId: params.seedAppId ?? void 0,
|
|
2912
|
+
seedHeadCommitHash: params.seedHeadCommitHash ?? void 0
|
|
2255
2913
|
}),
|
|
2256
2914
|
"project lane binding"
|
|
2257
2915
|
);
|
|
@@ -2271,6 +2929,175 @@ function branchBindingFromLane(lane, mode, fallback) {
|
|
|
2271
2929
|
bindingMode: mode
|
|
2272
2930
|
};
|
|
2273
2931
|
}
|
|
2932
|
+
function resolveStoredBindingAnchor(params) {
|
|
2933
|
+
const currentBranchBinding = params.currentBranch ? params.state.branchBindings[params.currentBranch] ?? null : null;
|
|
2934
|
+
if (currentBranchBinding?.currentAppId) {
|
|
2935
|
+
return {
|
|
2936
|
+
binding: currentBranchBinding,
|
|
2937
|
+
branchName: params.currentBranch
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
const defaultBranchBinding = params.defaultBranch ? params.state.branchBindings[params.defaultBranch] ?? null : null;
|
|
2941
|
+
if (defaultBranchBinding?.currentAppId) {
|
|
2942
|
+
return {
|
|
2943
|
+
binding: defaultBranchBinding,
|
|
2944
|
+
branchName: params.defaultBranch
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
return null;
|
|
2948
|
+
}
|
|
2949
|
+
async function trySeedEquivalentBranchBaseline(params) {
|
|
2950
|
+
if (!params.branchName || !params.defaultBranch || params.branchName === params.defaultBranch) {
|
|
2951
|
+
return null;
|
|
2952
|
+
}
|
|
2953
|
+
const bindingState = await readCollabBindingState(params.repoRoot);
|
|
2954
|
+
const defaultBranchBinding = bindingState?.branchBindings[params.defaultBranch] ?? null;
|
|
2955
|
+
if (!defaultBranchBinding?.currentAppId) {
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2958
|
+
if (params.upstreamAppId && defaultBranchBinding.currentAppId !== params.upstreamAppId) {
|
|
2959
|
+
return null;
|
|
2960
|
+
}
|
|
2961
|
+
const defaultBaseline = await readLocalBaseline({
|
|
2962
|
+
repoFingerprint: params.repoFingerprint,
|
|
2963
|
+
laneId: defaultBranchBinding.laneId,
|
|
2964
|
+
repoRoot: params.repoRoot
|
|
2965
|
+
});
|
|
2966
|
+
if (!defaultBaseline?.lastSnapshotHash || !defaultBaseline.lastServerHeadHash) {
|
|
2967
|
+
return null;
|
|
2968
|
+
}
|
|
2969
|
+
if (defaultBaseline.lastServerHeadHash !== params.appHeadHash) {
|
|
2970
|
+
return null;
|
|
2971
|
+
}
|
|
2972
|
+
const inspection = await inspectLocalSnapshot({
|
|
2973
|
+
repoRoot: params.repoRoot,
|
|
2974
|
+
repoFingerprint: params.repoFingerprint,
|
|
2975
|
+
laneId: params.laneId,
|
|
2976
|
+
branchName: params.branchName,
|
|
2977
|
+
persistBlobs: false
|
|
2978
|
+
});
|
|
2979
|
+
if (inspection.snapshotHash !== defaultBaseline.lastSnapshotHash) {
|
|
2980
|
+
return null;
|
|
2981
|
+
}
|
|
2982
|
+
const snapshot = await captureLocalSnapshot({
|
|
2983
|
+
repoRoot: params.repoRoot,
|
|
2984
|
+
repoFingerprint: params.repoFingerprint,
|
|
2985
|
+
laneId: params.laneId,
|
|
2986
|
+
branchName: params.branchName
|
|
2987
|
+
});
|
|
2988
|
+
await writeLocalBaseline({
|
|
2989
|
+
repoRoot: params.repoRoot,
|
|
2990
|
+
repoFingerprint: params.repoFingerprint,
|
|
2991
|
+
laneId: params.laneId,
|
|
2992
|
+
currentAppId: params.currentAppId,
|
|
2993
|
+
branchName: params.branchName,
|
|
2994
|
+
lastSnapshotId: snapshot.id,
|
|
2995
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
2996
|
+
lastServerHeadHash: params.appHeadHash,
|
|
2997
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
2998
|
+
});
|
|
2999
|
+
return "seeded";
|
|
3000
|
+
}
|
|
3001
|
+
async function resolveInitBaselineStatus(params) {
|
|
3002
|
+
const baseline = await readLocalBaseline({
|
|
3003
|
+
repoFingerprint: params.repoFingerprint,
|
|
3004
|
+
laneId: params.laneId,
|
|
3005
|
+
repoRoot: params.repoRoot
|
|
3006
|
+
});
|
|
3007
|
+
if (baseline?.lastSnapshotHash && baseline.lastServerHeadHash) {
|
|
3008
|
+
return "existing";
|
|
3009
|
+
}
|
|
3010
|
+
const localHeadCommitHash = await getHeadCommitHash(params.repoRoot);
|
|
3011
|
+
if (!localHeadCommitHash) return "requires_re_anchor";
|
|
3012
|
+
const appHead = unwrapResponseObject(
|
|
3013
|
+
await params.api.getAppHead(params.currentAppId),
|
|
3014
|
+
"app head"
|
|
3015
|
+
);
|
|
3016
|
+
if (localHeadCommitHash === appHead.headCommitHash) {
|
|
3017
|
+
await seedImportedInitBaseline({
|
|
3018
|
+
api: params.api,
|
|
3019
|
+
repoRoot: params.repoRoot,
|
|
3020
|
+
repoFingerprint: params.repoFingerprint,
|
|
3021
|
+
laneId: params.laneId,
|
|
3022
|
+
currentAppId: params.currentAppId,
|
|
3023
|
+
branchName: params.branchName
|
|
3024
|
+
});
|
|
3025
|
+
return "seeded";
|
|
3026
|
+
}
|
|
3027
|
+
const localSnapshotHash = params.branchName && params.defaultBranch && params.branchName !== params.defaultBranch ? (await inspectLocalSnapshot({
|
|
3028
|
+
repoRoot: params.repoRoot,
|
|
3029
|
+
repoFingerprint: params.repoFingerprint,
|
|
3030
|
+
laneId: params.laneId,
|
|
3031
|
+
branchName: params.branchName,
|
|
3032
|
+
persistBlobs: false
|
|
3033
|
+
})).snapshotHash : void 0;
|
|
3034
|
+
try {
|
|
3035
|
+
const deltaResp = await params.api.getAppDelta(params.currentAppId, {
|
|
3036
|
+
baseHeadHash: localHeadCommitHash,
|
|
3037
|
+
targetHeadHash: appHead.headCommitHash,
|
|
3038
|
+
localSnapshotHash,
|
|
3039
|
+
repoFingerprint: params.repoFingerprint,
|
|
3040
|
+
remoteUrl: params.remoteUrl ?? void 0,
|
|
3041
|
+
defaultBranch: params.defaultBranch ?? void 0
|
|
3042
|
+
});
|
|
3043
|
+
const delta = unwrapResponseObject(deltaResp, "app delta");
|
|
3044
|
+
if (delta.status === "up_to_date" || delta.status === "delta_ready") {
|
|
3045
|
+
return "requires_sync";
|
|
3046
|
+
}
|
|
3047
|
+
if (delta.status === "content_equivalent") {
|
|
3048
|
+
await seedImportedInitBaseline({
|
|
3049
|
+
api: params.api,
|
|
3050
|
+
repoRoot: params.repoRoot,
|
|
3051
|
+
repoFingerprint: params.repoFingerprint,
|
|
3052
|
+
laneId: params.laneId,
|
|
3053
|
+
currentAppId: params.currentAppId,
|
|
3054
|
+
branchName: params.branchName
|
|
3055
|
+
});
|
|
3056
|
+
return "seeded";
|
|
3057
|
+
}
|
|
3058
|
+
if (delta.status === "base_unknown") {
|
|
3059
|
+
const equivalentBaseline = await trySeedEquivalentBranchBaseline({
|
|
3060
|
+
repoRoot: params.repoRoot,
|
|
3061
|
+
repoFingerprint: params.repoFingerprint,
|
|
3062
|
+
laneId: params.laneId,
|
|
3063
|
+
currentAppId: params.currentAppId,
|
|
3064
|
+
upstreamAppId: params.upstreamAppId ?? null,
|
|
3065
|
+
branchName: params.branchName,
|
|
3066
|
+
defaultBranch: params.defaultBranch,
|
|
3067
|
+
appHeadHash: appHead.headCommitHash
|
|
3068
|
+
});
|
|
3069
|
+
if (equivalentBaseline) {
|
|
3070
|
+
return equivalentBaseline;
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
} catch {
|
|
3074
|
+
}
|
|
3075
|
+
return "requires_re_anchor";
|
|
3076
|
+
}
|
|
3077
|
+
async function seedImportedInitBaseline(params) {
|
|
3078
|
+
const appHead = unwrapResponseObject(
|
|
3079
|
+
await params.api.getAppHead(params.currentAppId),
|
|
3080
|
+
"app head"
|
|
3081
|
+
);
|
|
3082
|
+
const snapshot = await captureLocalSnapshot({
|
|
3083
|
+
repoRoot: params.repoRoot,
|
|
3084
|
+
repoFingerprint: params.repoFingerprint,
|
|
3085
|
+
laneId: params.laneId,
|
|
3086
|
+
branchName: params.branchName
|
|
3087
|
+
});
|
|
3088
|
+
await writeLocalBaseline({
|
|
3089
|
+
repoRoot: params.repoRoot,
|
|
3090
|
+
repoFingerprint: params.repoFingerprint,
|
|
3091
|
+
laneId: params.laneId,
|
|
3092
|
+
currentAppId: params.currentAppId,
|
|
3093
|
+
branchName: params.branchName,
|
|
3094
|
+
lastSnapshotId: snapshot.id,
|
|
3095
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
3096
|
+
lastServerHeadHash: appHead.headCommitHash,
|
|
3097
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
3098
|
+
});
|
|
3099
|
+
return "seeded";
|
|
3100
|
+
}
|
|
2274
3101
|
async function collabInit(params) {
|
|
2275
3102
|
return withRepoMutationLock(
|
|
2276
3103
|
{
|
|
@@ -2286,13 +3113,15 @@ async function collabInit(params) {
|
|
|
2286
3113
|
hint: "History-preserving init imports the full git repository. Run the command from the repository root without --path."
|
|
2287
3114
|
});
|
|
2288
3115
|
}
|
|
2289
|
-
const
|
|
3116
|
+
const localBindingState = await readCollabBindingState(repoRoot, { persist: true });
|
|
3117
|
+
const persistedRemoteUrl = normalizeGitRemote(localBindingState?.remoteUrl ?? null);
|
|
2290
3118
|
const currentBranch = await getCurrentBranch(repoRoot);
|
|
2291
|
-
const defaultBranch = await getDefaultBranch(repoRoot) ?? currentBranch;
|
|
3119
|
+
const defaultBranch = localBindingState?.defaultBranch ?? await getDefaultBranch(repoRoot) ?? currentBranch;
|
|
2292
3120
|
const branchName = currentBranch ?? defaultBranch ?? null;
|
|
2293
|
-
const
|
|
3121
|
+
const localHeadCommitHash = await getHeadCommitHash(repoRoot);
|
|
3122
|
+
const remoteUrl = persistedRemoteUrl ?? normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
|
|
3123
|
+
const repoFingerprint = localBindingState?.repoFingerprint ?? await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch });
|
|
2294
3124
|
const repoSnapshot = await captureRepoSnapshot(repoRoot);
|
|
2295
|
-
const localBindingState = await readCollabBindingState(repoRoot, { persist: true });
|
|
2296
3125
|
if (!params.forceNew && localBindingState?.explicitRootBinding && branchName) {
|
|
2297
3126
|
const explicitRoot = localBindingState.explicitRootBinding;
|
|
2298
3127
|
const explicitProjectId = explicitRoot.projectId ?? localBindingState.projectId;
|
|
@@ -2319,6 +3148,7 @@ async function collabInit(params) {
|
|
|
2319
3148
|
remoteUrl,
|
|
2320
3149
|
defaultBranch,
|
|
2321
3150
|
branchName,
|
|
3151
|
+
seedHeadCommitHash: localHeadCommitHash,
|
|
2322
3152
|
operation: "`remix collab init`"
|
|
2323
3153
|
});
|
|
2324
3154
|
boundProjectId2 = lane.projectId ?? boundProjectId2;
|
|
@@ -2381,10 +3211,133 @@ async function collabInit(params) {
|
|
|
2381
3211
|
appId: boundCurrentAppId2,
|
|
2382
3212
|
dashboardUrl: buildDashboardAppUrl(boundCurrentAppId2),
|
|
2383
3213
|
upstreamAppId: boundUpstreamAppId2,
|
|
2384
|
-
bindingPath:
|
|
3214
|
+
bindingPath: path7.join(repoRoot, ".remix", "config.json"),
|
|
2385
3215
|
repoRoot,
|
|
2386
3216
|
bindingMode: defaultBranch && branchName !== defaultBranch ? "lane" : "explicit_root",
|
|
2387
3217
|
createdCanonicalFamily: false,
|
|
3218
|
+
baselineStatus: await resolveInitBaselineStatus({
|
|
3219
|
+
api: params.api,
|
|
3220
|
+
repoRoot,
|
|
3221
|
+
repoFingerprint,
|
|
3222
|
+
laneId: boundLaneId2,
|
|
3223
|
+
currentAppId: boundCurrentAppId2,
|
|
3224
|
+
upstreamAppId: boundUpstreamAppId2,
|
|
3225
|
+
branchName,
|
|
3226
|
+
remoteUrl,
|
|
3227
|
+
defaultBranch
|
|
3228
|
+
}),
|
|
3229
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
const storedBindingAnchor = localBindingState && branchName ? resolveStoredBindingAnchor({
|
|
3233
|
+
state: localBindingState,
|
|
3234
|
+
currentBranch,
|
|
3235
|
+
defaultBranch
|
|
3236
|
+
}) : null;
|
|
3237
|
+
if (!params.forceNew && localBindingState && storedBindingAnchor && branchName) {
|
|
3238
|
+
const existingBinding = storedBindingAnchor.binding;
|
|
3239
|
+
const existingProjectId = existingBinding.projectId ?? localBindingState.projectId;
|
|
3240
|
+
let canonicalLane2 = null;
|
|
3241
|
+
let boundProjectId2 = existingProjectId;
|
|
3242
|
+
let boundCurrentAppId2 = existingBinding.currentAppId;
|
|
3243
|
+
let boundUpstreamAppId2 = existingBinding.upstreamAppId;
|
|
3244
|
+
let boundThreadId2 = existingBinding.threadId;
|
|
3245
|
+
let boundLaneId2 = existingBinding.laneId;
|
|
3246
|
+
if (defaultBranch && branchName !== defaultBranch) {
|
|
3247
|
+
canonicalLane2 = await resolveOrEnsureLaneBinding({
|
|
3248
|
+
api: params.api,
|
|
3249
|
+
projectId: existingProjectId ?? void 0,
|
|
3250
|
+
repoFingerprint,
|
|
3251
|
+
remoteUrl,
|
|
3252
|
+
defaultBranch,
|
|
3253
|
+
branchName: defaultBranch,
|
|
3254
|
+
operation: "`remix collab init`"
|
|
3255
|
+
});
|
|
3256
|
+
const lane = await resolveOrEnsureLaneBinding({
|
|
3257
|
+
api: params.api,
|
|
3258
|
+
projectId: canonicalLane2.projectId ?? existingProjectId ?? void 0,
|
|
3259
|
+
repoFingerprint,
|
|
3260
|
+
remoteUrl,
|
|
3261
|
+
defaultBranch,
|
|
3262
|
+
branchName,
|
|
3263
|
+
seedHeadCommitHash: localHeadCommitHash,
|
|
3264
|
+
operation: "`remix collab init`"
|
|
3265
|
+
});
|
|
3266
|
+
boundProjectId2 = lane.projectId ?? boundProjectId2;
|
|
3267
|
+
boundCurrentAppId2 = lane.currentAppId ?? boundCurrentAppId2;
|
|
3268
|
+
boundUpstreamAppId2 = lane.upstreamAppId ?? boundUpstreamAppId2;
|
|
3269
|
+
boundThreadId2 = lane.threadId ?? boundThreadId2;
|
|
3270
|
+
boundLaneId2 = lane.laneId ?? null;
|
|
3271
|
+
} else {
|
|
3272
|
+
canonicalLane2 = await resolveOrEnsureLaneBinding({
|
|
3273
|
+
api: params.api,
|
|
3274
|
+
projectId: existingProjectId ?? void 0,
|
|
3275
|
+
repoFingerprint,
|
|
3276
|
+
remoteUrl,
|
|
3277
|
+
defaultBranch,
|
|
3278
|
+
branchName,
|
|
3279
|
+
operation: "`remix collab init`"
|
|
3280
|
+
});
|
|
3281
|
+
boundProjectId2 = canonicalLane2.projectId ?? boundProjectId2;
|
|
3282
|
+
boundCurrentAppId2 = canonicalLane2.currentAppId ?? boundCurrentAppId2;
|
|
3283
|
+
boundUpstreamAppId2 = canonicalLane2.upstreamAppId ?? boundUpstreamAppId2;
|
|
3284
|
+
boundThreadId2 = canonicalLane2.threadId ?? boundThreadId2;
|
|
3285
|
+
boundLaneId2 = canonicalLane2.laneId ?? null;
|
|
3286
|
+
}
|
|
3287
|
+
const readyApp = await pollAppReady(params.api, boundCurrentAppId2);
|
|
3288
|
+
boundProjectId2 = String(readyApp.projectId ?? boundProjectId2);
|
|
3289
|
+
boundThreadId2 = readyApp.threadId ? String(readyApp.threadId) : boundThreadId2;
|
|
3290
|
+
await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
|
|
3291
|
+
operation: "`remix collab init`",
|
|
3292
|
+
recoveryHint: "The repository changed while the local binding was being initialized. Review the local changes and rerun `remix collab init`."
|
|
3293
|
+
});
|
|
3294
|
+
if (canonicalLane2 && defaultBranch && branchName !== defaultBranch) {
|
|
3295
|
+
await writeCollabBinding(repoRoot, {
|
|
3296
|
+
projectId: canonicalLane2.projectId ?? existingProjectId ?? null,
|
|
3297
|
+
currentAppId: canonicalLane2.currentAppId ?? existingBinding.currentAppId,
|
|
3298
|
+
upstreamAppId: canonicalLane2.upstreamAppId ?? canonicalLane2.currentAppId ?? existingBinding.upstreamAppId ?? existingBinding.currentAppId,
|
|
3299
|
+
threadId: canonicalLane2.threadId ?? existingBinding.threadId,
|
|
3300
|
+
repoFingerprint: canonicalLane2.repoFingerprint ?? localBindingState.repoFingerprint ?? repoFingerprint,
|
|
3301
|
+
remoteUrl: canonicalLane2.remoteUrl ?? localBindingState.remoteUrl ?? remoteUrl,
|
|
3302
|
+
defaultBranch: canonicalLane2.defaultBranch ?? localBindingState.defaultBranch ?? defaultBranch ?? null,
|
|
3303
|
+
laneId: canonicalLane2.laneId ?? null,
|
|
3304
|
+
branchName: defaultBranch,
|
|
3305
|
+
bindingMode: "lane"
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
const bindingPath2 = await writeCollabBinding(repoRoot, {
|
|
3309
|
+
projectId: boundProjectId2,
|
|
3310
|
+
currentAppId: boundCurrentAppId2,
|
|
3311
|
+
upstreamAppId: boundUpstreamAppId2,
|
|
3312
|
+
threadId: boundThreadId2,
|
|
3313
|
+
repoFingerprint,
|
|
3314
|
+
remoteUrl,
|
|
3315
|
+
defaultBranch: defaultBranch ?? null,
|
|
3316
|
+
laneId: boundLaneId2,
|
|
3317
|
+
branchName,
|
|
3318
|
+
bindingMode: "lane"
|
|
3319
|
+
});
|
|
3320
|
+
return {
|
|
3321
|
+
reused: true,
|
|
3322
|
+
projectId: boundProjectId2 ?? existingProjectId ?? "",
|
|
3323
|
+
appId: boundCurrentAppId2,
|
|
3324
|
+
dashboardUrl: buildDashboardAppUrl(boundCurrentAppId2),
|
|
3325
|
+
upstreamAppId: boundUpstreamAppId2,
|
|
3326
|
+
bindingPath: bindingPath2,
|
|
3327
|
+
repoRoot,
|
|
3328
|
+
bindingMode: "lane",
|
|
3329
|
+
createdCanonicalFamily: false,
|
|
3330
|
+
baselineStatus: await resolveInitBaselineStatus({
|
|
3331
|
+
api: params.api,
|
|
3332
|
+
repoRoot,
|
|
3333
|
+
repoFingerprint,
|
|
3334
|
+
laneId: boundLaneId2,
|
|
3335
|
+
currentAppId: boundCurrentAppId2,
|
|
3336
|
+
upstreamAppId: boundUpstreamAppId2,
|
|
3337
|
+
branchName,
|
|
3338
|
+
remoteUrl,
|
|
3339
|
+
defaultBranch
|
|
3340
|
+
}),
|
|
2388
3341
|
...warnings.length > 0 ? { warnings } : {}
|
|
2389
3342
|
};
|
|
2390
3343
|
}
|
|
@@ -2433,6 +3386,7 @@ async function collabInit(params) {
|
|
|
2433
3386
|
remoteUrl,
|
|
2434
3387
|
defaultBranch,
|
|
2435
3388
|
branchName,
|
|
3389
|
+
seedHeadCommitHash: localHeadCommitHash,
|
|
2436
3390
|
operation: "`remix collab init`"
|
|
2437
3391
|
});
|
|
2438
3392
|
boundProjectId2 = lane.projectId ?? boundProjectId2;
|
|
@@ -2503,13 +3457,24 @@ async function collabInit(params) {
|
|
|
2503
3457
|
repoRoot,
|
|
2504
3458
|
bindingMode: "lane",
|
|
2505
3459
|
createdCanonicalFamily: false,
|
|
3460
|
+
baselineStatus: await resolveInitBaselineStatus({
|
|
3461
|
+
api: params.api,
|
|
3462
|
+
repoRoot,
|
|
3463
|
+
repoFingerprint,
|
|
3464
|
+
laneId: boundLaneId2,
|
|
3465
|
+
currentAppId: boundCurrentAppId2,
|
|
3466
|
+
upstreamAppId: boundUpstreamAppId2,
|
|
3467
|
+
branchName,
|
|
3468
|
+
remoteUrl,
|
|
3469
|
+
defaultBranch
|
|
3470
|
+
}),
|
|
2506
3471
|
...warnings.length > 0 ? { warnings } : {}
|
|
2507
3472
|
};
|
|
2508
3473
|
}
|
|
2509
3474
|
}
|
|
2510
3475
|
const { bundlePath, headCommitHash } = await createGitBundle(repoRoot, "repository.bundle");
|
|
2511
3476
|
const bundleSha = await sha256FileHex(bundlePath);
|
|
2512
|
-
const bundleSize = (await
|
|
3477
|
+
const bundleSize = (await fs8.stat(bundlePath)).size;
|
|
2513
3478
|
const presignResp = await params.api.presignImportUploadFirstParty({
|
|
2514
3479
|
file: {
|
|
2515
3480
|
name: "repository.bundle",
|
|
@@ -2526,7 +3491,7 @@ async function collabInit(params) {
|
|
|
2526
3491
|
});
|
|
2527
3492
|
const importResp = await params.api.importFromUploadFirstParty({
|
|
2528
3493
|
uploadId: String(presign.uploadId),
|
|
2529
|
-
appName: params.appName?.trim() ||
|
|
3494
|
+
appName: params.appName?.trim() || path7.basename(repoRoot),
|
|
2530
3495
|
path: params.path?.trim() || void 0,
|
|
2531
3496
|
platform: "generic",
|
|
2532
3497
|
isPublic: false,
|
|
@@ -2563,6 +3528,7 @@ async function collabInit(params) {
|
|
|
2563
3528
|
remoteUrl,
|
|
2564
3529
|
defaultBranch,
|
|
2565
3530
|
branchName,
|
|
3531
|
+
seedHeadCommitHash: headCommitHash,
|
|
2566
3532
|
operation: "`remix collab init`"
|
|
2567
3533
|
});
|
|
2568
3534
|
boundProjectId = lane.projectId ?? boundProjectId;
|
|
@@ -2656,6 +3622,14 @@ async function collabInit(params) {
|
|
|
2656
3622
|
bindingMode
|
|
2657
3623
|
});
|
|
2658
3624
|
}
|
|
3625
|
+
const baselineStatus = await seedImportedInitBaseline({
|
|
3626
|
+
api: params.api,
|
|
3627
|
+
repoRoot,
|
|
3628
|
+
repoFingerprint,
|
|
3629
|
+
laneId: boundLaneId,
|
|
3630
|
+
currentAppId: boundCurrentAppId,
|
|
3631
|
+
branchName
|
|
3632
|
+
});
|
|
2659
3633
|
return {
|
|
2660
3634
|
reused: false,
|
|
2661
3635
|
projectId: boundProjectId,
|
|
@@ -2666,6 +3640,7 @@ async function collabInit(params) {
|
|
|
2666
3640
|
repoRoot,
|
|
2667
3641
|
bindingMode,
|
|
2668
3642
|
createdCanonicalFamily: Boolean(params.forceNew),
|
|
3643
|
+
baselineStatus,
|
|
2669
3644
|
remoteUrl,
|
|
2670
3645
|
defaultBranch,
|
|
2671
3646
|
...warnings.length > 0 ? { warnings } : {}
|
|
@@ -2695,32 +3670,207 @@ async function collabInvite(params) {
|
|
|
2695
3670
|
scopeType: scope,
|
|
2696
3671
|
targetId
|
|
2697
3672
|
};
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
// src/application/collab/collabList.ts
|
|
2701
|
-
async function collabList(params) {
|
|
2702
|
-
const pageRequest = normalizePagination(params);
|
|
2703
|
-
const resp = await params.api.listApps({
|
|
2704
|
-
ownership: params.ownership ?? "all",
|
|
2705
|
-
accessScope: params.accessScope ?? "explicit_member",
|
|
2706
|
-
createdBy: params.createdBy,
|
|
2707
|
-
forked: params.forked ?? "all",
|
|
2708
|
-
limit: pageRequest.limit + 1,
|
|
2709
|
-
offset: pageRequest.offset
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
// src/application/collab/collabList.ts
|
|
3676
|
+
async function collabList(params) {
|
|
3677
|
+
const pageRequest = normalizePagination(params);
|
|
3678
|
+
const resp = await params.api.listApps({
|
|
3679
|
+
ownership: params.ownership ?? "all",
|
|
3680
|
+
accessScope: params.accessScope ?? "explicit_member",
|
|
3681
|
+
createdBy: params.createdBy,
|
|
3682
|
+
forked: params.forked ?? "all",
|
|
3683
|
+
limit: pageRequest.limit + 1,
|
|
3684
|
+
offset: pageRequest.offset
|
|
3685
|
+
});
|
|
3686
|
+
const apps = unwrapResponseObject(resp, "apps");
|
|
3687
|
+
const page = paginateOverfetchedItems(apps, params);
|
|
3688
|
+
return {
|
|
3689
|
+
apps: page.items,
|
|
3690
|
+
pagination: page.pagination
|
|
3691
|
+
};
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
// src/application/collab/collabReAnchor.ts
|
|
3695
|
+
import fs9 from "fs/promises";
|
|
3696
|
+
import path8 from "path";
|
|
3697
|
+
|
|
3698
|
+
// src/application/collab/pendingFinalize.ts
|
|
3699
|
+
function hasPendingFinalize(summary) {
|
|
3700
|
+
return Boolean(summary && summary.state !== "idle" && summary.activeJobCount > 0);
|
|
3701
|
+
}
|
|
3702
|
+
function buildPendingFinalizeHint() {
|
|
3703
|
+
return "Drain or await the local finalize queue first, then retry after the queued Remix turn finishes recording remotely.";
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// src/application/collab/collabReAnchor.ts
|
|
3707
|
+
async function collabReAnchor(params) {
|
|
3708
|
+
const repoRoot = await findGitRoot(params.cwd);
|
|
3709
|
+
const binding = await ensureActiveLaneBinding({
|
|
3710
|
+
repoRoot,
|
|
3711
|
+
api: params.api,
|
|
3712
|
+
operation: "`remix collab re-anchor`"
|
|
3713
|
+
});
|
|
3714
|
+
if (!binding) {
|
|
3715
|
+
throw new RemixError("Repository is not bound to Remix.", {
|
|
3716
|
+
exitCode: 2,
|
|
3717
|
+
hint: "Run `remix collab init` first."
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
const detected = await collabDetectRepoState({
|
|
3721
|
+
api: params.api,
|
|
3722
|
+
cwd: repoRoot,
|
|
3723
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
3724
|
+
});
|
|
3725
|
+
if (detected.status === "metadata_conflict" || detected.status === "branch_mismatch") {
|
|
3726
|
+
throw new RemixError("Repository must be realigned before re-anchoring upstream history.", {
|
|
3727
|
+
exitCode: 2,
|
|
3728
|
+
hint: detected.hint
|
|
3729
|
+
});
|
|
3730
|
+
}
|
|
3731
|
+
if (detected.status !== "ready" || !detected.binding) {
|
|
3732
|
+
throw new RemixError(detected.hint || "Repository is not ready for re-anchor.", {
|
|
3733
|
+
exitCode: 2,
|
|
3734
|
+
hint: detected.hint
|
|
3735
|
+
});
|
|
3736
|
+
}
|
|
3737
|
+
if (detected.repoState === "server_only_changed") {
|
|
3738
|
+
throw new RemixError("This checkout is already on a server-known base and only needs a local pull.", {
|
|
3739
|
+
exitCode: 2,
|
|
3740
|
+
hint: "Run `remix collab sync` instead of `remix collab re-anchor`."
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
if (detected.repoState === "both_changed") {
|
|
3744
|
+
throw new RemixError("Both the local workspace and the server lane changed since the last agreed baseline.", {
|
|
3745
|
+
exitCode: 2,
|
|
3746
|
+
hint: "Run `remix collab reconcile` to replay the local boundary onto the newer server head."
|
|
3747
|
+
});
|
|
3748
|
+
}
|
|
3749
|
+
if (detected.repoState === "local_only_changed") {
|
|
3750
|
+
if (hasPendingFinalize(detected.pendingFinalize)) {
|
|
3751
|
+
throw new RemixError("Re-anchor is not needed while queued Remix turn recording is still processing.", {
|
|
3752
|
+
exitCode: 2,
|
|
3753
|
+
hint: buildPendingFinalizeHint()
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
throw new RemixError("Re-anchor is not needed while only local boundary changes are pending.", {
|
|
3757
|
+
exitCode: 2,
|
|
3758
|
+
hint: "Record the local work with `remix collab finalize-turn` instead. Use `remix collab re-anchor` only when you need to adopt external Git/GitHub history as the new Remix anchor."
|
|
3759
|
+
});
|
|
3760
|
+
}
|
|
3761
|
+
if (detected.repoState === "idle") {
|
|
3762
|
+
throw new RemixError("This checkout is already aligned with Remix.", {
|
|
3763
|
+
exitCode: 2,
|
|
3764
|
+
hint: "No re-anchor step is needed right now unless you are explicitly re-anchoring to external Git/GitHub history."
|
|
3765
|
+
});
|
|
3766
|
+
}
|
|
3767
|
+
await ensureCleanWorktree(repoRoot, "`remix collab re-anchor`");
|
|
3768
|
+
const branch = await requireCurrentBranch(repoRoot);
|
|
3769
|
+
const headCommitHash = await getHeadCommitHash(repoRoot);
|
|
3770
|
+
if (!headCommitHash) {
|
|
3771
|
+
throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
|
|
3772
|
+
}
|
|
3773
|
+
const preflightResp = await params.api.preflightAppReconcile(binding.currentAppId, {
|
|
3774
|
+
localHeadCommitHash: headCommitHash,
|
|
3775
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
3776
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
3777
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
3778
|
+
});
|
|
3779
|
+
const preflight = unwrapResponseObject(preflightResp, "reconcile preflight");
|
|
3780
|
+
if (preflight.status === "metadata_conflict") {
|
|
3781
|
+
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
3782
|
+
exitCode: 2,
|
|
3783
|
+
hint: preflight.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
3784
|
+
});
|
|
3785
|
+
}
|
|
3786
|
+
const preview = {
|
|
3787
|
+
status: preflight.status === "up_to_date" ? "reanchored" : "re_anchor_required",
|
|
3788
|
+
repoRoot,
|
|
3789
|
+
branch,
|
|
3790
|
+
currentAppId: binding.currentAppId,
|
|
3791
|
+
localHeadCommitHash: headCommitHash,
|
|
3792
|
+
targetHeadCommitHash: preflight.targetHeadCommitHash,
|
|
3793
|
+
targetHeadCommitId: preflight.targetHeadCommitId,
|
|
3794
|
+
warnings: preflight.warnings,
|
|
3795
|
+
applied: false,
|
|
3796
|
+
dryRun: params.dryRun === true
|
|
3797
|
+
};
|
|
3798
|
+
if (params.dryRun) {
|
|
3799
|
+
return preview;
|
|
3800
|
+
}
|
|
3801
|
+
let anchoredServerHeadHash = preflight.targetHeadCommitHash;
|
|
3802
|
+
if (preflight.status === "ready_to_reconcile") {
|
|
3803
|
+
const { bundlePath, headCommitHash: bundledHeadCommitHash } = await createGitBundle(repoRoot, "re-anchor.bundle");
|
|
3804
|
+
const bundleTempDir = path8.dirname(bundlePath);
|
|
3805
|
+
try {
|
|
3806
|
+
const bundleStat = await fs9.stat(bundlePath);
|
|
3807
|
+
const checksumSha256 = await sha256FileHex(bundlePath);
|
|
3808
|
+
const presignResp = await params.api.presignImportUploadFirstParty({
|
|
3809
|
+
file: {
|
|
3810
|
+
name: path8.basename(bundlePath),
|
|
3811
|
+
mimeType: "application/x-git-bundle",
|
|
3812
|
+
size: bundleStat.size,
|
|
3813
|
+
checksumSha256
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
const uploadTarget = unwrapResponseObject(presignResp, "import upload target");
|
|
3817
|
+
await uploadPresigned({
|
|
3818
|
+
uploadUrl: String(uploadTarget.uploadUrl),
|
|
3819
|
+
filePath: bundlePath,
|
|
3820
|
+
headers: uploadTarget.headers ?? {}
|
|
3821
|
+
});
|
|
3822
|
+
const startResp = await params.api.startAppReconcile(binding.currentAppId, {
|
|
3823
|
+
uploadId: String(uploadTarget.uploadId),
|
|
3824
|
+
localHeadCommitHash: bundledHeadCommitHash,
|
|
3825
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
3826
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
3827
|
+
defaultBranch: binding.defaultBranch ?? void 0,
|
|
3828
|
+
idempotencyKey: buildDeterministicIdempotencyKey({
|
|
3829
|
+
kind: "collab_re_anchor_v1",
|
|
3830
|
+
appId: binding.currentAppId,
|
|
3831
|
+
localHeadCommitHash: bundledHeadCommitHash,
|
|
3832
|
+
targetHeadCommitHash: preflight.targetHeadCommitHash
|
|
3833
|
+
})
|
|
3834
|
+
});
|
|
3835
|
+
const started = unwrapResponseObject(startResp, "reconcile");
|
|
3836
|
+
const reconcile = await pollReconcile(params.api, binding.currentAppId, started.id);
|
|
3837
|
+
anchoredServerHeadHash = reconcile.reconciledHeadCommitHash ?? reconcile.targetHeadCommitHash ?? preflight.targetHeadCommitHash;
|
|
3838
|
+
} finally {
|
|
3839
|
+
await fs9.rm(bundleTempDir, { recursive: true, force: true });
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
const snapshot = await captureLocalSnapshot({
|
|
3843
|
+
repoRoot,
|
|
3844
|
+
repoFingerprint: binding.repoFingerprint,
|
|
3845
|
+
laneId: binding.laneId,
|
|
3846
|
+
branchName: binding.branchName
|
|
3847
|
+
});
|
|
3848
|
+
await writeLocalBaseline({
|
|
3849
|
+
repoRoot,
|
|
3850
|
+
repoFingerprint: binding.repoFingerprint,
|
|
3851
|
+
laneId: binding.laneId,
|
|
3852
|
+
currentAppId: binding.currentAppId,
|
|
3853
|
+
branchName: binding.branchName,
|
|
3854
|
+
lastSnapshotId: snapshot.id,
|
|
3855
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
3856
|
+
lastServerHeadHash: anchoredServerHeadHash,
|
|
3857
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
2710
3858
|
});
|
|
2711
|
-
const apps = unwrapResponseObject(resp, "apps");
|
|
2712
|
-
const page = paginateOverfetchedItems(apps, params);
|
|
2713
3859
|
return {
|
|
2714
|
-
|
|
2715
|
-
|
|
3860
|
+
...preview,
|
|
3861
|
+
status: "reanchored",
|
|
3862
|
+
targetHeadCommitHash: anchoredServerHeadHash,
|
|
3863
|
+
applied: true,
|
|
3864
|
+
dryRun: false
|
|
2716
3865
|
};
|
|
2717
3866
|
}
|
|
2718
3867
|
|
|
2719
3868
|
// src/application/collab/collabReconcile.ts
|
|
2720
|
-
import
|
|
2721
|
-
import
|
|
2722
|
-
import
|
|
2723
|
-
|
|
3869
|
+
import fs10 from "fs/promises";
|
|
3870
|
+
import os5 from "os";
|
|
3871
|
+
import path9 from "path";
|
|
3872
|
+
import { execa as execa2 } from "execa";
|
|
3873
|
+
async function reconcileBothChanged(params) {
|
|
2724
3874
|
const repoRoot = await findGitRoot(params.cwd);
|
|
2725
3875
|
const binding = await ensureActiveLaneBinding({
|
|
2726
3876
|
repoRoot,
|
|
@@ -2733,163 +3883,229 @@ async function collabReconcile(params) {
|
|
|
2733
3883
|
hint: "Run `remix collab init` first."
|
|
2734
3884
|
});
|
|
2735
3885
|
}
|
|
2736
|
-
await ensureCleanWorktree(repoRoot, "`remix collab reconcile`");
|
|
2737
3886
|
const branch = await requireCurrentBranch(repoRoot);
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
operation: "`remix collab reconcile`"
|
|
3887
|
+
const baseline = await readLocalBaseline({
|
|
3888
|
+
repoFingerprint: binding.repoFingerprint,
|
|
3889
|
+
laneId: binding.laneId,
|
|
3890
|
+
repoRoot
|
|
2743
3891
|
});
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
3892
|
+
if (!baseline?.lastSnapshotId || !baseline.lastServerHeadHash) {
|
|
3893
|
+
throw new RemixError("Local Remix baseline is missing for this lane.", {
|
|
3894
|
+
exitCode: 2,
|
|
3895
|
+
hint: "Run `remix collab re-anchor` to create a fresh baseline first."
|
|
3896
|
+
});
|
|
2748
3897
|
}
|
|
2749
|
-
const
|
|
2750
|
-
|
|
2751
|
-
repoFingerprint: binding.repoFingerprint
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
dryRun: true
|
|
3898
|
+
const currentSnapshot = await captureLocalSnapshot({
|
|
3899
|
+
repoRoot,
|
|
3900
|
+
repoFingerprint: binding.repoFingerprint,
|
|
3901
|
+
laneId: binding.laneId,
|
|
3902
|
+
branchName: binding.branchName
|
|
2755
3903
|
});
|
|
2756
|
-
const
|
|
2757
|
-
|
|
2758
|
-
|
|
3904
|
+
const diffResult = await diffLocalSnapshots({
|
|
3905
|
+
baseSnapshotId: baseline.lastSnapshotId,
|
|
3906
|
+
targetSnapshotId: currentSnapshot.id
|
|
3907
|
+
});
|
|
3908
|
+
if (!diffResult.diff.trim()) {
|
|
3909
|
+
throw new RemixError("No local boundary delta was found to reconcile.", {
|
|
2759
3910
|
exitCode: 2,
|
|
2760
|
-
hint:
|
|
3911
|
+
hint: "Pull the server delta locally, or make local changes before running `remix collab reconcile`."
|
|
2761
3912
|
});
|
|
2762
3913
|
}
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
3914
|
+
const [appHeadResp, deltaResp, repoSnapshot] = await Promise.all([
|
|
3915
|
+
params.api.getAppHead(binding.currentAppId),
|
|
3916
|
+
params.api.getAppDelta(binding.currentAppId, {
|
|
3917
|
+
baseHeadHash: baseline.lastServerHeadHash,
|
|
3918
|
+
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
3919
|
+
remoteUrl: binding.remoteUrl ?? void 0,
|
|
3920
|
+
defaultBranch: binding.defaultBranch ?? void 0
|
|
3921
|
+
}),
|
|
3922
|
+
captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true })
|
|
3923
|
+
]);
|
|
3924
|
+
const appHead = unwrapResponseObject(appHeadResp, "app head");
|
|
3925
|
+
const delta = unwrapResponseObject(deltaResp, "app delta");
|
|
3926
|
+
if (delta.status === "conflict_risk") {
|
|
2774
3927
|
throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
|
|
2775
3928
|
exitCode: 2,
|
|
2776
|
-
hint:
|
|
3929
|
+
hint: delta.warnings.join("\n") || "Run the command from the correct bound repository."
|
|
2777
3930
|
});
|
|
2778
3931
|
}
|
|
2779
|
-
if (
|
|
2780
|
-
|
|
3932
|
+
if (delta.status === "base_unknown") {
|
|
3933
|
+
throw new RemixError("Reconcile cannot pull the newer server state from the last acknowledged baseline.", {
|
|
3934
|
+
exitCode: 2,
|
|
3935
|
+
hint: "Run `remix collab re-anchor` to re-anchor this checkout before retrying."
|
|
3936
|
+
});
|
|
2781
3937
|
}
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
3938
|
+
if (delta.status !== "delta_ready" && delta.status !== "up_to_date") {
|
|
3939
|
+
throw new RemixError("Reconcile could not determine the server delta for this lane.", {
|
|
3940
|
+
exitCode: 1,
|
|
3941
|
+
hint: delta.warnings.join("\n") || "Run `remix collab status` and retry once the lane is healthy."
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
const preview = {
|
|
3945
|
+
status: "ready_to_reconcile",
|
|
2785
3946
|
repoRoot,
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
3947
|
+
branch,
|
|
3948
|
+
currentAppId: binding.currentAppId,
|
|
3949
|
+
localHeadCommitHash: currentSnapshot.localCommitHash,
|
|
3950
|
+
baseHeadCommitHash: baseline.lastServerHeadHash,
|
|
3951
|
+
targetHeadCommitHash: appHead.headCommitHash,
|
|
3952
|
+
targetHeadCommitId: appHead.headCommitId,
|
|
3953
|
+
stats: diffResult.stats,
|
|
3954
|
+
warnings: delta.warnings,
|
|
2790
3955
|
applied: false,
|
|
2791
3956
|
dryRun: params.dryRun
|
|
2792
3957
|
};
|
|
2793
3958
|
if (params.dryRun) {
|
|
2794
|
-
return
|
|
3959
|
+
return preview;
|
|
2795
3960
|
}
|
|
2796
|
-
const
|
|
2797
|
-
|
|
3961
|
+
const replayResp = await params.api.startChangeStepReplay(binding.currentAppId, {
|
|
3962
|
+
prompt: "Reconcile local boundary delta onto the latest server head.",
|
|
3963
|
+
assistantResponse: "Replay the local boundary delta onto the latest server head without recording a new change step.",
|
|
3964
|
+
diff: diffResult.diff,
|
|
3965
|
+
baseCommitHash: baseline.lastServerHeadHash,
|
|
3966
|
+
targetHeadCommitHash: appHead.headCommitHash,
|
|
3967
|
+
expectedPaths: diffResult.changedPaths,
|
|
3968
|
+
workspaceMetadata: {
|
|
3969
|
+
recordingMode: "boundary_delta",
|
|
3970
|
+
repoRoot,
|
|
3971
|
+
branch,
|
|
3972
|
+
baselineSnapshotId: baseline.lastSnapshotId,
|
|
3973
|
+
currentSnapshotId: currentSnapshot.id,
|
|
3974
|
+
baselineServerHeadHash: baseline.lastServerHeadHash,
|
|
3975
|
+
currentSnapshotHash: currentSnapshot.snapshotHash,
|
|
3976
|
+
localCommitHash: currentSnapshot.localCommitHash,
|
|
3977
|
+
repoStateAtCapture: "both_changed"
|
|
3978
|
+
},
|
|
3979
|
+
idempotencyKey: buildDeterministicIdempotencyKey({
|
|
3980
|
+
kind: "collab_reconcile_replay_v1",
|
|
3981
|
+
appId: binding.currentAppId,
|
|
3982
|
+
baseCommitHash: baseline.lastServerHeadHash,
|
|
3983
|
+
targetHeadCommitHash: appHead.headCommitHash,
|
|
3984
|
+
currentSnapshotId: currentSnapshot.id,
|
|
3985
|
+
diffSha256: diffResult.diffSha256
|
|
3986
|
+
})
|
|
3987
|
+
});
|
|
3988
|
+
const replayStart = unwrapResponseObject(replayResp, "change step replay");
|
|
3989
|
+
const replay = await pollChangeStepReplay(params.api, binding.currentAppId, String(replayStart.id));
|
|
3990
|
+
const replayDiffResp = await params.api.getChangeStepReplayDiff(binding.currentAppId, replay.id);
|
|
3991
|
+
const replayDiff = unwrapResponseObject(replayDiffResp, "change step replay diff");
|
|
3992
|
+
const tempRoot = await fs10.mkdtemp(path9.join(os5.tmpdir(), "remix-reconcile-"));
|
|
3993
|
+
let serverHeadSnapshot = null;
|
|
3994
|
+
let mergedSnapshot = null;
|
|
2798
3995
|
try {
|
|
2799
|
-
const
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
uploadUrl: String(uploadTarget.uploadUrl),
|
|
2812
|
-
filePath: bundlePath,
|
|
2813
|
-
headers: uploadTarget.headers ?? {}
|
|
3996
|
+
const tempRepoRoot = path9.join(tempRoot, "repo");
|
|
3997
|
+
await fs10.mkdir(tempRepoRoot, { recursive: true });
|
|
3998
|
+
await execa2("git", ["init"], { cwd: tempRepoRoot, stderr: "ignore" });
|
|
3999
|
+
await materializeLocalSnapshot(baseline.lastSnapshotId, tempRepoRoot);
|
|
4000
|
+
if (delta.status === "delta_ready" && delta.diff.trim()) {
|
|
4001
|
+
await applyUnifiedDiffToWorktree(tempRepoRoot, delta.diff, "`remix collab reconcile`");
|
|
4002
|
+
}
|
|
4003
|
+
serverHeadSnapshot = await captureLocalSnapshot({
|
|
4004
|
+
repoRoot: tempRepoRoot,
|
|
4005
|
+
repoFingerprint: binding.repoFingerprint,
|
|
4006
|
+
laneId: binding.laneId,
|
|
4007
|
+
branchName: binding.branchName
|
|
2814
4008
|
});
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
localHeadCommitHash: bundledHeadCommitHash,
|
|
2824
|
-
targetHeadCommitHash: preflight.targetHeadCommitHash
|
|
2825
|
-
})
|
|
4009
|
+
if (replayDiff.diff.trim()) {
|
|
4010
|
+
await applyUnifiedDiffToWorktree(tempRepoRoot, replayDiff.diff, "`remix collab reconcile`");
|
|
4011
|
+
}
|
|
4012
|
+
mergedSnapshot = await captureLocalSnapshot({
|
|
4013
|
+
repoRoot: tempRepoRoot,
|
|
4014
|
+
repoFingerprint: binding.repoFingerprint,
|
|
4015
|
+
laneId: binding.laneId,
|
|
4016
|
+
branchName: binding.branchName
|
|
2826
4017
|
});
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
4018
|
+
} finally {
|
|
4019
|
+
await fs10.rm(tempRoot, { recursive: true, force: true }).catch(() => void 0);
|
|
4020
|
+
}
|
|
4021
|
+
if (!serverHeadSnapshot || !mergedSnapshot) {
|
|
4022
|
+
throw new RemixError("Failed to materialize the reconciled local workspace.", { exitCode: 1 });
|
|
4023
|
+
}
|
|
4024
|
+
const workspaceChanged = currentSnapshot.snapshotHash !== mergedSnapshot.snapshotHash;
|
|
4025
|
+
return withRepoMutationLock(
|
|
4026
|
+
{
|
|
4027
|
+
cwd: repoRoot,
|
|
4028
|
+
operation: "collabReconcile"
|
|
4029
|
+
},
|
|
4030
|
+
async ({ repoRoot: lockedRepoRoot, warnings }) => {
|
|
4031
|
+
await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
|
|
4032
|
+
operation: "`remix collab reconcile`",
|
|
4033
|
+
recoveryHint: "The repository changed while reconcile was preparing the replay. Review the local changes and rerun `remix collab reconcile`."
|
|
2832
4034
|
});
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
operation: "`remix collab reconcile`",
|
|
2845
|
-
recoveryHint: "The repository changed after reconcile was prepared. Review the local changes and rerun `remix collab reconcile`."
|
|
2846
|
-
});
|
|
2847
|
-
await ensureCleanWorktree(lockedRepoRoot, "`remix collab reconcile`");
|
|
2848
|
-
const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
|
|
2849
|
-
assertBoundBranchMatch({
|
|
2850
|
-
currentBranch: lockedBranch,
|
|
2851
|
-
branchName: binding.branchName,
|
|
2852
|
-
allowBranchMismatch: params.allowBranchMismatch,
|
|
2853
|
-
operation: "`remix collab reconcile`"
|
|
2854
|
-
});
|
|
2855
|
-
const backup = await createBackupBranch(lockedRepoRoot, {
|
|
2856
|
-
branchName: branch,
|
|
2857
|
-
sourceCommitHash: headCommitHash,
|
|
2858
|
-
prefix: "remix/reconcile-backup"
|
|
4035
|
+
await restoreLocalSnapshotToWorktree(mergedSnapshot.id, lockedRepoRoot);
|
|
4036
|
+
const restoredSnapshot = await captureLocalSnapshot({
|
|
4037
|
+
repoRoot: lockedRepoRoot,
|
|
4038
|
+
repoFingerprint: binding.repoFingerprint,
|
|
4039
|
+
laneId: binding.laneId,
|
|
4040
|
+
branchName: binding.branchName
|
|
4041
|
+
});
|
|
4042
|
+
if (restoredSnapshot.snapshotHash !== mergedSnapshot.snapshotHash) {
|
|
4043
|
+
throw new RemixError("Failed to restore the reconciled workspace into the local checkout.", {
|
|
4044
|
+
exitCode: 1,
|
|
4045
|
+
hint: "The local worktree did not match the reconciled snapshot after restore."
|
|
2859
4046
|
});
|
|
2860
|
-
await hardResetToCommit(lockedRepoRoot, mergeBaseCommitHash, "`remix collab reconcile`");
|
|
2861
|
-
const bundleResp = await params.api.downloadAppReconcileBundle(binding.currentAppId, reconcile.id);
|
|
2862
|
-
const resultTempDir = await fs8.mkdtemp(path6.join(os4.tmpdir(), "remix-reconcile-"));
|
|
2863
|
-
const resultBundlePath = path6.join(resultTempDir, bundleResp.fileName ?? "reconcile-result.bundle");
|
|
2864
|
-
try {
|
|
2865
|
-
await fs8.writeFile(resultBundlePath, bundleResp.data);
|
|
2866
|
-
await importGitBundle(lockedRepoRoot, resultBundlePath, resultBundleRef);
|
|
2867
|
-
await ensureCommitExists(lockedRepoRoot, reconciledHeadCommitHash);
|
|
2868
|
-
const localCommitHash = await fastForwardToCommit(lockedRepoRoot, reconciledHeadCommitHash);
|
|
2869
|
-
if (localCommitHash !== reconciledHeadCommitHash) {
|
|
2870
|
-
throw new RemixError("Local reconcile completed but final HEAD does not match the server result.", { exitCode: 1 });
|
|
2871
|
-
}
|
|
2872
|
-
return {
|
|
2873
|
-
...previewResult,
|
|
2874
|
-
status: reconcile.status,
|
|
2875
|
-
reconcileId: reconcile.id,
|
|
2876
|
-
mergeBaseCommitHash: reconcile.mergeBaseCommitHash,
|
|
2877
|
-
reconciledHeadCommitId: reconcile.reconciledHeadCommitId,
|
|
2878
|
-
reconciledHeadCommitHash: reconcile.reconciledHeadCommitHash,
|
|
2879
|
-
backupBranchName: backup.branchName,
|
|
2880
|
-
localCommitHash,
|
|
2881
|
-
applied: true,
|
|
2882
|
-
dryRun: false,
|
|
2883
|
-
warnings: Array.from(/* @__PURE__ */ new Set([...previewResult.warnings, ...warnings]))
|
|
2884
|
-
};
|
|
2885
|
-
} finally {
|
|
2886
|
-
await fs8.rm(resultTempDir, { recursive: true, force: true });
|
|
2887
|
-
}
|
|
2888
4047
|
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
4048
|
+
await writeLocalBaseline({
|
|
4049
|
+
repoRoot: lockedRepoRoot,
|
|
4050
|
+
repoFingerprint: binding.repoFingerprint,
|
|
4051
|
+
laneId: binding.laneId,
|
|
4052
|
+
currentAppId: binding.currentAppId,
|
|
4053
|
+
branchName: binding.branchName,
|
|
4054
|
+
lastSnapshotId: serverHeadSnapshot.id,
|
|
4055
|
+
lastSnapshotHash: serverHeadSnapshot.snapshotHash,
|
|
4056
|
+
lastServerHeadHash: appHead.headCommitHash,
|
|
4057
|
+
lastSeenLocalCommitHash: restoredSnapshot.localCommitHash
|
|
4058
|
+
});
|
|
4059
|
+
return {
|
|
4060
|
+
...preview,
|
|
4061
|
+
status: "reconciled",
|
|
4062
|
+
localHeadCommitHash: restoredSnapshot.localCommitHash,
|
|
4063
|
+
applied: workspaceChanged,
|
|
4064
|
+
dryRun: false,
|
|
4065
|
+
...warnings.length > 0 ? {
|
|
4066
|
+
warnings: Array.from(/* @__PURE__ */ new Set([...delta.warnings, ...warnings]))
|
|
4067
|
+
} : {}
|
|
4068
|
+
};
|
|
4069
|
+
}
|
|
4070
|
+
);
|
|
4071
|
+
}
|
|
4072
|
+
async function collabReconcile(params) {
|
|
4073
|
+
const detected = await collabDetectRepoState({
|
|
4074
|
+
api: params.api,
|
|
4075
|
+
cwd: params.cwd,
|
|
4076
|
+
allowBranchMismatch: params.allowBranchMismatch
|
|
4077
|
+
});
|
|
4078
|
+
if (detected.status === "metadata_conflict" || detected.status === "branch_mismatch") {
|
|
4079
|
+
throw new RemixError("Repository must be realigned before reconciliation.", {
|
|
4080
|
+
exitCode: 2,
|
|
4081
|
+
hint: detected.hint
|
|
4082
|
+
});
|
|
4083
|
+
}
|
|
4084
|
+
if (detected.repoState === "server_only_changed" || detected.repoState === "idle") {
|
|
4085
|
+
return collabSync(params);
|
|
4086
|
+
}
|
|
4087
|
+
if (detected.repoState === "both_changed") {
|
|
4088
|
+
return reconcileBothChanged(params);
|
|
4089
|
+
}
|
|
4090
|
+
if (detected.repoState === "external_local_base_changed") {
|
|
4091
|
+
return collabReAnchor(params);
|
|
2892
4092
|
}
|
|
4093
|
+
if (detected.repoState === "local_only_changed") {
|
|
4094
|
+
if (hasPendingFinalize(detected.pendingFinalize)) {
|
|
4095
|
+
throw new RemixError("Reconcile is not needed while queued Remix turn recording is still processing.", {
|
|
4096
|
+
exitCode: 2,
|
|
4097
|
+
hint: buildPendingFinalizeHint()
|
|
4098
|
+
});
|
|
4099
|
+
}
|
|
4100
|
+
throw new RemixError("Reconcile is not needed while only local boundary changes are pending.", {
|
|
4101
|
+
exitCode: 2,
|
|
4102
|
+
hint: "Record the local work with `remix collab finalize-turn` instead."
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
throw new RemixError(detected.hint || "Repository is not ready for reconciliation.", {
|
|
4106
|
+
exitCode: 2,
|
|
4107
|
+
hint: detected.hint
|
|
4108
|
+
});
|
|
2893
4109
|
}
|
|
2894
4110
|
|
|
2895
4111
|
// src/application/collab/collabReject.ts
|
|
@@ -2961,9 +4177,34 @@ async function collabRemix(params) {
|
|
|
2961
4177
|
branchName: authoritativeLane?.branchName ?? branchName,
|
|
2962
4178
|
bindingMode: "lane"
|
|
2963
4179
|
});
|
|
4180
|
+
const currentAppId = authoritativeLane?.currentAppId ?? String(app.id);
|
|
4181
|
+
const repoFingerprintForBaseline = authoritativeLane?.repoFingerprint ?? repoFingerprint;
|
|
4182
|
+
const laneIdForBaseline = authoritativeLane?.laneId ?? laneId;
|
|
4183
|
+
const branchNameForBaseline = authoritativeLane?.branchName ?? branchName;
|
|
4184
|
+
const appHead = unwrapResponseObject(
|
|
4185
|
+
await params.api.getAppHead(currentAppId),
|
|
4186
|
+
"app head"
|
|
4187
|
+
);
|
|
4188
|
+
const snapshot = await captureLocalSnapshot({
|
|
4189
|
+
repoRoot: checkout.repoRoot,
|
|
4190
|
+
repoFingerprint: repoFingerprintForBaseline,
|
|
4191
|
+
laneId: laneIdForBaseline,
|
|
4192
|
+
branchName: branchNameForBaseline
|
|
4193
|
+
});
|
|
4194
|
+
await writeLocalBaseline({
|
|
4195
|
+
repoRoot: checkout.repoRoot,
|
|
4196
|
+
repoFingerprint: repoFingerprintForBaseline,
|
|
4197
|
+
laneId: laneIdForBaseline,
|
|
4198
|
+
currentAppId,
|
|
4199
|
+
branchName: branchNameForBaseline,
|
|
4200
|
+
lastSnapshotId: snapshot.id,
|
|
4201
|
+
lastSnapshotHash: snapshot.snapshotHash,
|
|
4202
|
+
lastServerHeadHash: appHead.headCommitHash,
|
|
4203
|
+
lastSeenLocalCommitHash: snapshot.localCommitHash
|
|
4204
|
+
});
|
|
2964
4205
|
return {
|
|
2965
|
-
appId:
|
|
2966
|
-
dashboardUrl: buildDashboardAppUrl(
|
|
4206
|
+
appId: currentAppId,
|
|
4207
|
+
dashboardUrl: buildDashboardAppUrl(currentAppId),
|
|
2967
4208
|
projectId: authoritativeLane?.projectId ?? String(app.projectId),
|
|
2968
4209
|
upstreamAppId: authoritativeLane?.upstreamAppId ?? String(app.forkedFromAppId ?? sourceAppId),
|
|
2969
4210
|
bindingPath,
|
|
@@ -2980,11 +4221,26 @@ async function collabRequestMerge(params) {
|
|
|
2980
4221
|
operation: "`remix collab request-merge`"
|
|
2981
4222
|
});
|
|
2982
4223
|
if (!binding) throw new RemixError("Repository is not bound to Remix.", { exitCode: 2 });
|
|
4224
|
+
const pendingFinalize = await summarizePendingFinalizeJobs({
|
|
4225
|
+
repoRoot,
|
|
4226
|
+
repoFingerprint: binding.repoFingerprint,
|
|
4227
|
+
currentAppId: binding.currentAppId,
|
|
4228
|
+
laneId: binding.laneId
|
|
4229
|
+
});
|
|
4230
|
+
if (hasPendingFinalize(pendingFinalize)) {
|
|
4231
|
+
throw new RemixError("Pending Remix turn recording still processing.", {
|
|
4232
|
+
exitCode: 2,
|
|
4233
|
+
hint: buildPendingFinalizeHint()
|
|
4234
|
+
});
|
|
4235
|
+
}
|
|
2983
4236
|
const resp = await params.api.openMergeRequest(binding.currentAppId);
|
|
2984
4237
|
return unwrapResponseObject(resp, "merge request");
|
|
2985
4238
|
}
|
|
2986
4239
|
|
|
2987
4240
|
// src/application/collab/collabStatus.ts
|
|
4241
|
+
function isFinalizeQueueBlocking(state) {
|
|
4242
|
+
return state === "queued" || state === "processing" || state === "retry_scheduled";
|
|
4243
|
+
}
|
|
2988
4244
|
function createBaseStatus() {
|
|
2989
4245
|
return {
|
|
2990
4246
|
schemaVersion: 1,
|
|
@@ -3045,6 +4301,36 @@ function createBaseStatus() {
|
|
|
3045
4301
|
targetHeadCommitHash: null,
|
|
3046
4302
|
targetHeadCommitId: null
|
|
3047
4303
|
},
|
|
4304
|
+
alignment: {
|
|
4305
|
+
checked: false,
|
|
4306
|
+
error: null,
|
|
4307
|
+
repoState: null,
|
|
4308
|
+
canRecordTurn: false,
|
|
4309
|
+
pendingFinalize: {
|
|
4310
|
+
state: "idle",
|
|
4311
|
+
activeJobCount: 0,
|
|
4312
|
+
queuedJobCount: 0,
|
|
4313
|
+
processingJobCount: 0,
|
|
4314
|
+
retryScheduledJobCount: 0,
|
|
4315
|
+
failedJobCount: 0,
|
|
4316
|
+
oldestCapturedAt: null,
|
|
4317
|
+
newestCapturedAt: null,
|
|
4318
|
+
nextRetryAt: null,
|
|
4319
|
+
latestError: null
|
|
4320
|
+
},
|
|
4321
|
+
baseline: {
|
|
4322
|
+
lastSnapshotId: null,
|
|
4323
|
+
lastSnapshotHash: null,
|
|
4324
|
+
lastServerHeadHash: null,
|
|
4325
|
+
lastSeenLocalCommitHash: null
|
|
4326
|
+
},
|
|
4327
|
+
current: {
|
|
4328
|
+
snapshotHash: null,
|
|
4329
|
+
serverHeadHash: null,
|
|
4330
|
+
serverHeadCommitId: null,
|
|
4331
|
+
localCommitHash: null
|
|
4332
|
+
}
|
|
4333
|
+
},
|
|
3048
4334
|
recommendedAction: "no_action",
|
|
3049
4335
|
warnings: []
|
|
3050
4336
|
};
|
|
@@ -3079,15 +4365,13 @@ async function collabStatus(params) {
|
|
|
3079
4365
|
}
|
|
3080
4366
|
status.repo.isGitRepo = true;
|
|
3081
4367
|
status.repo.repoRoot = repoRoot;
|
|
3082
|
-
const [branch,
|
|
4368
|
+
const [branch, headCommitHash, worktreeStatus, detected] = await Promise.all([
|
|
3083
4369
|
getCurrentBranch(repoRoot),
|
|
3084
4370
|
getHeadCommitHash(repoRoot),
|
|
3085
4371
|
getWorktreeStatus(repoRoot),
|
|
3086
|
-
|
|
4372
|
+
collabDetectRepoState({ api: params.api ?? void 0, cwd: repoRoot })
|
|
3087
4373
|
]);
|
|
3088
|
-
let headCommitHash = initialHeadCommitHash;
|
|
3089
4374
|
status.repo.branch = branch;
|
|
3090
|
-
status.repo.branchMismatch = false;
|
|
3091
4375
|
status.repo.headCommitHash = headCommitHash;
|
|
3092
4376
|
status.repo.worktree = {
|
|
3093
4377
|
isClean: worktreeStatus.isClean,
|
|
@@ -3099,213 +4383,154 @@ async function collabStatus(params) {
|
|
|
3099
4383
|
if (!status.repo.worktree.isClean) addWarning(status, "Working tree has local changes.");
|
|
3100
4384
|
if (!branch) addWarning(status, "Repository is in a detached HEAD state.");
|
|
3101
4385
|
if (!headCommitHash) addWarning(status, "Failed to resolve local HEAD commit.");
|
|
3102
|
-
if (
|
|
3103
|
-
status.binding.path = null;
|
|
3104
|
-
addBlockedReason(status.sync, "not_bound");
|
|
3105
|
-
addBlockedReason(status.reconcile, "not_bound");
|
|
3106
|
-
status.recommendedAction = "init";
|
|
3107
|
-
return status;
|
|
3108
|
-
}
|
|
3109
|
-
if (bindingResolution.status === "missing_branch_binding") {
|
|
3110
|
-
status.binding = {
|
|
3111
|
-
isBound: true,
|
|
3112
|
-
path: getCollabBindingPath(repoRoot),
|
|
3113
|
-
projectId: bindingResolution.projectId,
|
|
3114
|
-
currentAppId: null,
|
|
3115
|
-
upstreamAppId: bindingResolution.upstreamAppId,
|
|
3116
|
-
isRemix: null,
|
|
3117
|
-
threadId: bindingResolution.threadId,
|
|
3118
|
-
repoFingerprint: bindingResolution.repoFingerprint,
|
|
3119
|
-
remoteUrl: bindingResolution.remoteUrl,
|
|
3120
|
-
defaultBranch: bindingResolution.defaultBranch,
|
|
3121
|
-
laneId: null,
|
|
3122
|
-
branchName: bindingResolution.currentBranch,
|
|
3123
|
-
bindingMode: "lane"
|
|
3124
|
-
};
|
|
3125
|
-
addBlockedReason(status.sync, "branch_binding_missing");
|
|
3126
|
-
addBlockedReason(status.reconcile, "branch_binding_missing");
|
|
3127
|
-
addWarning(status, `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`);
|
|
3128
|
-
status.recommendedAction = "init";
|
|
3129
|
-
return status;
|
|
3130
|
-
}
|
|
3131
|
-
if (bindingResolution.status === "ambiguous_family_selection") {
|
|
4386
|
+
if (detected.binding) {
|
|
3132
4387
|
status.binding = {
|
|
3133
4388
|
isBound: true,
|
|
3134
4389
|
path: getCollabBindingPath(repoRoot),
|
|
3135
|
-
projectId:
|
|
3136
|
-
currentAppId:
|
|
3137
|
-
upstreamAppId:
|
|
3138
|
-
isRemix:
|
|
3139
|
-
threadId:
|
|
3140
|
-
repoFingerprint:
|
|
3141
|
-
remoteUrl:
|
|
3142
|
-
defaultBranch:
|
|
3143
|
-
laneId:
|
|
3144
|
-
branchName:
|
|
3145
|
-
bindingMode:
|
|
4390
|
+
projectId: detected.binding.projectId,
|
|
4391
|
+
currentAppId: detected.binding.currentAppId,
|
|
4392
|
+
upstreamAppId: detected.binding.upstreamAppId,
|
|
4393
|
+
isRemix: detected.binding.currentAppId !== detected.binding.upstreamAppId,
|
|
4394
|
+
threadId: detected.binding.threadId,
|
|
4395
|
+
repoFingerprint: detected.binding.repoFingerprint,
|
|
4396
|
+
remoteUrl: detected.binding.remoteUrl,
|
|
4397
|
+
defaultBranch: detected.binding.defaultBranch,
|
|
4398
|
+
laneId: detected.binding.laneId,
|
|
4399
|
+
branchName: detected.binding.branchName,
|
|
4400
|
+
bindingMode: detected.binding.bindingMode
|
|
3146
4401
|
};
|
|
3147
|
-
|
|
3148
|
-
addBlockedReason(status.reconcile, "family_ambiguous");
|
|
3149
|
-
addWarning(
|
|
3150
|
-
status,
|
|
3151
|
-
`Multiple canonical Remix families match ${bindingResolution.currentBranch ?? "the current branch"}. Switch to a checkout already bound to the intended family or run \`remix collab init --force-new\`.`
|
|
3152
|
-
);
|
|
3153
|
-
status.recommendedAction = "choose_family";
|
|
3154
|
-
return status;
|
|
4402
|
+
status.repo.branchMismatch = branch !== detected.binding.branchName;
|
|
3155
4403
|
}
|
|
3156
|
-
|
|
3157
|
-
status.
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
remoteUrl: binding.remoteUrl,
|
|
3167
|
-
defaultBranch: binding.defaultBranch,
|
|
3168
|
-
laneId: binding.laneId,
|
|
3169
|
-
branchName: binding.branchName,
|
|
3170
|
-
bindingMode: binding.bindingMode
|
|
4404
|
+
status.alignment.checked = Boolean(params.api);
|
|
4405
|
+
status.alignment.repoState = detected.repoState;
|
|
4406
|
+
status.alignment.error = detected.status === "remote_error" ? detected.hint : null;
|
|
4407
|
+
status.alignment.pendingFinalize = detected.pendingFinalize;
|
|
4408
|
+
status.alignment.baseline = detected.baseline;
|
|
4409
|
+
status.alignment.current = {
|
|
4410
|
+
snapshotHash: detected.currentSnapshotHash,
|
|
4411
|
+
serverHeadHash: detected.currentServerHeadHash,
|
|
4412
|
+
serverHeadCommitId: detected.currentServerHeadCommitId,
|
|
4413
|
+
localCommitHash: detected.localCommitHash
|
|
3171
4414
|
};
|
|
3172
|
-
status.
|
|
3173
|
-
|
|
4415
|
+
status.alignment.canRecordTurn = !isFinalizeQueueBlocking(detected.pendingFinalize.state) && (detected.repoState === "idle" || detected.repoState === "local_only_changed");
|
|
4416
|
+
switch (detected.status) {
|
|
4417
|
+
case "not_bound":
|
|
4418
|
+
addBlockedReason(status.sync, "not_bound");
|
|
4419
|
+
addBlockedReason(status.reconcile, "not_bound");
|
|
4420
|
+
status.recommendedAction = "init";
|
|
4421
|
+
break;
|
|
4422
|
+
case "branch_binding_missing":
|
|
4423
|
+
addBlockedReason(status.sync, "branch_binding_missing");
|
|
4424
|
+
addBlockedReason(status.reconcile, "branch_binding_missing");
|
|
4425
|
+
status.recommendedAction = "init";
|
|
4426
|
+
break;
|
|
4427
|
+
case "family_ambiguous":
|
|
4428
|
+
addBlockedReason(status.sync, "family_ambiguous");
|
|
4429
|
+
addBlockedReason(status.reconcile, "family_ambiguous");
|
|
4430
|
+
status.recommendedAction = "choose_family";
|
|
4431
|
+
break;
|
|
4432
|
+
case "missing_head":
|
|
4433
|
+
addBlockedReason(status.sync, "missing_head");
|
|
4434
|
+
addBlockedReason(status.reconcile, "missing_head");
|
|
4435
|
+
break;
|
|
4436
|
+
case "branch_mismatch":
|
|
4437
|
+
addBlockedReason(status.sync, "branch_mismatch");
|
|
4438
|
+
addBlockedReason(status.reconcile, "branch_mismatch");
|
|
4439
|
+
break;
|
|
4440
|
+
case "metadata_conflict":
|
|
4441
|
+
addBlockedReason(status.sync, "metadata_conflict");
|
|
4442
|
+
addBlockedReason(status.reconcile, "metadata_conflict");
|
|
4443
|
+
status.sync.checked = true;
|
|
4444
|
+
status.sync.status = "conflict_risk";
|
|
4445
|
+
status.sync.warnings = detected.metadataWarnings;
|
|
4446
|
+
status.reconcile.checked = true;
|
|
4447
|
+
status.reconcile.status = "metadata_conflict";
|
|
4448
|
+
status.reconcile.warnings = detected.metadataWarnings;
|
|
4449
|
+
break;
|
|
4450
|
+
case "remote_error":
|
|
4451
|
+
addBlockedReason(status.sync, "remote_error");
|
|
4452
|
+
addBlockedReason(status.reconcile, "remote_error");
|
|
4453
|
+
status.sync.error = detected.hint;
|
|
4454
|
+
status.reconcile.error = detected.hint;
|
|
4455
|
+
break;
|
|
4456
|
+
default:
|
|
4457
|
+
break;
|
|
4458
|
+
}
|
|
4459
|
+
if (detected.repoState === "server_only_changed") {
|
|
4460
|
+
status.sync.checked = true;
|
|
4461
|
+
status.sync.status = "delta_ready";
|
|
4462
|
+
status.sync.targetCommitHash = detected.currentServerHeadHash;
|
|
4463
|
+
status.sync.targetCommitId = detected.currentServerHeadCommitId;
|
|
4464
|
+
status.sync.canApply = !status.repo.branchMismatch;
|
|
4465
|
+
status.recommendedAction = "pull";
|
|
4466
|
+
} else if (detected.repoState === "both_changed") {
|
|
4467
|
+
status.reconcile.checked = true;
|
|
4468
|
+
status.reconcile.status = "ready_to_reconcile";
|
|
4469
|
+
status.reconcile.targetHeadCommitHash = detected.currentServerHeadHash;
|
|
4470
|
+
status.reconcile.targetHeadCommitId = detected.currentServerHeadCommitId;
|
|
4471
|
+
status.reconcile.canApply = !status.repo.branchMismatch;
|
|
4472
|
+
status.recommendedAction = "reconcile";
|
|
4473
|
+
} else if (detected.repoState === "external_local_base_changed") {
|
|
4474
|
+
status.recommendedAction = "re_anchor";
|
|
4475
|
+
addBlockedReason(status.sync, "baseline_missing");
|
|
4476
|
+
addBlockedReason(status.reconcile, "baseline_missing");
|
|
4477
|
+
} else if (detected.repoState === "local_only_changed") {
|
|
4478
|
+
status.recommendedAction = isFinalizeQueueBlocking(detected.pendingFinalize.state) ? "await_finalize" : "record";
|
|
4479
|
+
} else if (detected.repoState === "idle") {
|
|
4480
|
+
status.recommendedAction = isFinalizeQueueBlocking(detected.pendingFinalize.state) ? "await_finalize" : "no_action";
|
|
4481
|
+
}
|
|
4482
|
+
if (isFinalizeQueueBlocking(detected.pendingFinalize.state) && detected.status === "ready" && status.recommendedAction !== "init" && status.recommendedAction !== "choose_family") {
|
|
4483
|
+
status.recommendedAction = "await_finalize";
|
|
3174
4484
|
addWarning(
|
|
3175
4485
|
status,
|
|
3176
|
-
|
|
4486
|
+
"A captured Remix turn is still pending locally. Drain or await finalize before opening a merge request or starting another recovery flow."
|
|
3177
4487
|
);
|
|
4488
|
+
addWarning(status, detected.pendingFinalize.latestError);
|
|
3178
4489
|
}
|
|
3179
|
-
if (
|
|
4490
|
+
if (detected.pendingFinalize.failedJobCount > 0) {
|
|
3180
4491
|
addWarning(
|
|
3181
4492
|
status,
|
|
3182
|
-
|
|
4493
|
+
"One or more queued finalize jobs failed permanently and will not retry automatically. Review the local finalize queue state before relying on those older captured turns."
|
|
3183
4494
|
);
|
|
4495
|
+
addWarning(status, detected.pendingFinalize.latestError);
|
|
3184
4496
|
}
|
|
3185
|
-
if (
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
defaultBranch: binding.defaultBranch ?? void 0,
|
|
3198
|
-
dryRun: true
|
|
3199
|
-
}) : Promise.resolve(null)
|
|
3200
|
-
]);
|
|
3201
|
-
const remoteErrors = [];
|
|
3202
|
-
if (appResult.status === "fulfilled") {
|
|
3203
|
-
const app = unwrapResponseObject(appResult.value, "app");
|
|
3204
|
-
status.remote.appStatus = typeof app.status === "string" ? app.status : null;
|
|
3205
|
-
} else {
|
|
3206
|
-
remoteErrors.push(formatCliErrorDetail(appResult.reason) ?? "Failed to fetch app status.");
|
|
3207
|
-
}
|
|
3208
|
-
if (incomingResult.status === "fulfilled") {
|
|
3209
|
-
const incoming = unwrapResponseObject(incomingResult.value, "merge requests");
|
|
3210
|
-
status.remote.incomingOpenMergeRequestCount = countMergeRequests(incoming);
|
|
3211
|
-
} else {
|
|
3212
|
-
remoteErrors.push(formatCliErrorDetail(incomingResult.reason) ?? "Failed to fetch incoming merge requests.");
|
|
3213
|
-
}
|
|
3214
|
-
if (outgoingResult.status === "fulfilled") {
|
|
3215
|
-
const outgoing = unwrapResponseObject(outgoingResult.value, "merge requests");
|
|
3216
|
-
status.remote.outgoingOpenMergeRequestCount = countMergeRequests(outgoing);
|
|
3217
|
-
} else {
|
|
3218
|
-
remoteErrors.push(formatCliErrorDetail(outgoingResult.reason) ?? "Failed to fetch outgoing merge requests.");
|
|
3219
|
-
}
|
|
3220
|
-
status.remote.checked = remoteErrors.length === 0;
|
|
3221
|
-
status.remote.error = remoteErrors.length > 0 ? remoteErrors.join("\n\n") : null;
|
|
3222
|
-
addWarning(status, status.remote.error);
|
|
3223
|
-
if (!headCommitHash) {
|
|
3224
|
-
addBlockedReason(status.sync, "missing_head");
|
|
3225
|
-
addBlockedReason(status.reconcile, "missing_head");
|
|
3226
|
-
} else if (syncResult.status === "fulfilled") {
|
|
3227
|
-
const syncResp = syncResult.value;
|
|
3228
|
-
if (syncResp) {
|
|
3229
|
-
const sync = unwrapResponseObject(syncResp, "sync result");
|
|
3230
|
-
const latestHeadCommitHash = sync.targetCommitHash && sync.status !== "up_to_date" ? await getHeadCommitHash(repoRoot) : headCommitHash;
|
|
3231
|
-
if (latestHeadCommitHash) {
|
|
3232
|
-
headCommitHash = latestHeadCommitHash;
|
|
3233
|
-
status.repo.headCommitHash = latestHeadCommitHash;
|
|
3234
|
-
}
|
|
3235
|
-
const normalizedSyncStatus = headCommitHash && sync.targetCommitHash && headCommitHash === sync.targetCommitHash ? "up_to_date" : sync.status;
|
|
3236
|
-
status.sync.checked = true;
|
|
3237
|
-
status.sync.status = normalizedSyncStatus;
|
|
3238
|
-
status.sync.warnings = sync.warnings;
|
|
3239
|
-
status.sync.targetCommitHash = sync.targetCommitHash;
|
|
3240
|
-
status.sync.targetCommitId = sync.targetCommitId;
|
|
3241
|
-
status.sync.stats = sync.stats;
|
|
3242
|
-
if (!status.repo.worktree.isClean) addBlockedReason(status.sync, "dirty_worktree");
|
|
3243
|
-
if (!branch) addBlockedReason(status.sync, "detached_head");
|
|
3244
|
-
if (status.repo.branchMismatch) addBlockedReason(status.sync, "branch_mismatch");
|
|
3245
|
-
if (normalizedSyncStatus === "conflict_risk") addBlockedReason(status.sync, "metadata_conflict");
|
|
3246
|
-
status.sync.canApply = status.sync.status === "ready_to_fast_forward" && status.repo.worktree.isClean && Boolean(branch) && !status.sync.blockedReasons.includes("metadata_conflict");
|
|
3247
|
-
if (normalizedSyncStatus === "conflict_risk") {
|
|
3248
|
-
status.reconcile.checked = true;
|
|
3249
|
-
status.reconcile.status = "metadata_conflict";
|
|
3250
|
-
status.reconcile.warnings = sync.warnings;
|
|
3251
|
-
status.reconcile.targetHeadCommitHash = sync.targetCommitHash;
|
|
3252
|
-
status.reconcile.targetHeadCommitId = sync.targetCommitId;
|
|
3253
|
-
addBlockedReason(status.reconcile, "metadata_conflict");
|
|
3254
|
-
if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
|
|
3255
|
-
if (!branch) addBlockedReason(status.reconcile, "detached_head");
|
|
3256
|
-
if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
|
|
3257
|
-
} else if (normalizedSyncStatus === "base_unknown") {
|
|
3258
|
-
try {
|
|
3259
|
-
const preflightResp = await params.api.preflightAppReconcile(binding.currentAppId, {
|
|
3260
|
-
localHeadCommitHash: headCommitHash,
|
|
3261
|
-
repoFingerprint: binding.repoFingerprint ?? void 0,
|
|
3262
|
-
remoteUrl: binding.remoteUrl ?? void 0,
|
|
3263
|
-
defaultBranch: binding.defaultBranch ?? void 0
|
|
3264
|
-
});
|
|
3265
|
-
const preflight = unwrapResponseObject(preflightResp, "reconcile preflight");
|
|
3266
|
-
status.reconcile.checked = true;
|
|
3267
|
-
status.reconcile.status = preflight.status;
|
|
3268
|
-
status.reconcile.warnings = preflight.warnings;
|
|
3269
|
-
status.reconcile.targetHeadCommitHash = preflight.targetHeadCommitHash;
|
|
3270
|
-
status.reconcile.targetHeadCommitId = preflight.targetHeadCommitId;
|
|
3271
|
-
if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
|
|
3272
|
-
if (!branch) addBlockedReason(status.reconcile, "detached_head");
|
|
3273
|
-
if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
|
|
3274
|
-
if (preflight.status === "metadata_conflict") addBlockedReason(status.reconcile, "metadata_conflict");
|
|
3275
|
-
status.reconcile.canApply = preflight.status === "ready_to_reconcile" && status.repo.worktree.isClean && Boolean(branch) && !status.reconcile.blockedReasons.includes("metadata_conflict");
|
|
3276
|
-
} catch (err) {
|
|
3277
|
-
status.reconcile.error = formatCliErrorDetail(err) ?? "Failed to fetch reconcile preflight.";
|
|
3278
|
-
addBlockedReason(status.reconcile, "remote_error");
|
|
3279
|
-
addWarning(status, status.reconcile.error);
|
|
3280
|
-
}
|
|
3281
|
-
} else {
|
|
3282
|
-
status.reconcile.checked = true;
|
|
3283
|
-
status.reconcile.status = "not_needed";
|
|
3284
|
-
if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
|
|
3285
|
-
if (!branch) addBlockedReason(status.reconcile, "detached_head");
|
|
3286
|
-
if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
|
|
3287
|
-
}
|
|
4497
|
+
if (params.api && status.binding.currentAppId) {
|
|
4498
|
+
const [appResult, incomingResult, outgoingResult] = await Promise.allSettled([
|
|
4499
|
+
params.api.getApp(status.binding.currentAppId),
|
|
4500
|
+
params.api.listMergeRequests({ queue: "app_reviewable", appId: status.binding.currentAppId, status: "open", kind: "merge" }),
|
|
4501
|
+
params.api.listMergeRequests({ queue: "app_outgoing", appId: status.binding.currentAppId, status: "open", kind: "merge" })
|
|
4502
|
+
]);
|
|
4503
|
+
const remoteErrors = [];
|
|
4504
|
+
if (appResult.status === "fulfilled") {
|
|
4505
|
+
const app = unwrapResponseObject(appResult.value, "app");
|
|
4506
|
+
status.remote.appStatus = typeof app.status === "string" ? app.status : null;
|
|
4507
|
+
} else {
|
|
4508
|
+
remoteErrors.push(formatCliErrorDetail(appResult.reason) ?? "Failed to fetch app status.");
|
|
3288
4509
|
}
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
4510
|
+
if (incomingResult.status === "fulfilled") {
|
|
4511
|
+
status.remote.incomingOpenMergeRequestCount = countMergeRequests(
|
|
4512
|
+
unwrapResponseObject(incomingResult.value, "merge requests")
|
|
4513
|
+
);
|
|
4514
|
+
} else {
|
|
4515
|
+
remoteErrors.push(formatCliErrorDetail(incomingResult.reason) ?? "Failed to fetch incoming merge requests.");
|
|
4516
|
+
}
|
|
4517
|
+
if (outgoingResult.status === "fulfilled") {
|
|
4518
|
+
status.remote.outgoingOpenMergeRequestCount = countMergeRequests(
|
|
4519
|
+
unwrapResponseObject(outgoingResult.value, "merge requests")
|
|
4520
|
+
);
|
|
4521
|
+
} else {
|
|
4522
|
+
remoteErrors.push(formatCliErrorDetail(outgoingResult.reason) ?? "Failed to fetch outgoing merge requests.");
|
|
4523
|
+
}
|
|
4524
|
+
status.remote.checked = remoteErrors.length === 0;
|
|
4525
|
+
status.remote.error = remoteErrors.length > 0 ? remoteErrors.join("\n\n") : null;
|
|
3294
4526
|
}
|
|
4527
|
+
addWarning(status, detected.hint);
|
|
4528
|
+
addWarning(status, status.remote.error);
|
|
4529
|
+
for (const warning of detected.warnings) addWarning(status, warning);
|
|
3295
4530
|
for (const warning of status.sync.warnings) addWarning(status, warning);
|
|
3296
4531
|
for (const warning of status.reconcile.warnings) addWarning(status, warning);
|
|
3297
|
-
if (
|
|
3298
|
-
status.recommendedAction = "init";
|
|
3299
|
-
} else if (status.sync.canApply && status.sync.status === "ready_to_fast_forward") {
|
|
3300
|
-
status.recommendedAction = "sync";
|
|
3301
|
-
} else if (status.reconcile.canApply && status.reconcile.status === "ready_to_reconcile") {
|
|
3302
|
-
status.recommendedAction = "reconcile";
|
|
3303
|
-
} else if ((status.remote.incomingOpenMergeRequestCount ?? 0) > 0) {
|
|
4532
|
+
if ((status.remote.incomingOpenMergeRequestCount ?? 0) > 0 && status.recommendedAction === "no_action") {
|
|
3304
4533
|
status.recommendedAction = "review_queue";
|
|
3305
|
-
} else if (status.sync.blockedReasons.includes("family_ambiguous") || status.reconcile.blockedReasons.includes("family_ambiguous")) {
|
|
3306
|
-
status.recommendedAction = "choose_family";
|
|
3307
|
-
} else {
|
|
3308
|
-
status.recommendedAction = "no_action";
|
|
3309
4534
|
}
|
|
3310
4535
|
return status;
|
|
3311
4536
|
}
|
|
@@ -3324,7 +4549,13 @@ async function collabSyncUpstream(params) {
|
|
|
3324
4549
|
hint: "Run `remix collab init` first."
|
|
3325
4550
|
});
|
|
3326
4551
|
}
|
|
3327
|
-
await
|
|
4552
|
+
await ensureWorkspaceMatchesBaseline({
|
|
4553
|
+
repoRoot,
|
|
4554
|
+
repoFingerprint: binding.repoFingerprint,
|
|
4555
|
+
laneId: binding.laneId,
|
|
4556
|
+
branchName: binding.branchName,
|
|
4557
|
+
operation: "`remix collab sync-upstream`"
|
|
4558
|
+
});
|
|
3328
4559
|
await requireCurrentBranch(repoRoot);
|
|
3329
4560
|
const repoSnapshot = await captureRepoSnapshot(repoRoot);
|
|
3330
4561
|
if (binding.currentAppId === binding.upstreamAppId) {
|
|
@@ -3362,7 +4593,13 @@ async function collabSyncUpstream(params) {
|
|
|
3362
4593
|
operation: "`remix collab sync-upstream`",
|
|
3363
4594
|
recoveryHint: "The repository changed while upstream sync was in progress. Review the local changes and rerun `remix collab sync-upstream`."
|
|
3364
4595
|
});
|
|
3365
|
-
await
|
|
4596
|
+
await ensureWorkspaceMatchesBaseline({
|
|
4597
|
+
repoRoot: lockedRepoRoot,
|
|
4598
|
+
repoFingerprint: binding.repoFingerprint,
|
|
4599
|
+
laneId: binding.laneId,
|
|
4600
|
+
branchName: binding.branchName,
|
|
4601
|
+
operation: "`remix collab sync-upstream`"
|
|
4602
|
+
});
|
|
3366
4603
|
await requireCurrentBranch(lockedRepoRoot);
|
|
3367
4604
|
const localSync = await collabSync({
|
|
3368
4605
|
api: params.api,
|
|
@@ -3397,7 +4634,6 @@ async function collabView(params) {
|
|
|
3397
4634
|
return unwrapMergeRequestReview(resp);
|
|
3398
4635
|
}
|
|
3399
4636
|
export {
|
|
3400
|
-
collabAdd,
|
|
3401
4637
|
collabApprove,
|
|
3402
4638
|
collabCheckout,
|
|
3403
4639
|
collabFinalizeTurn,
|
|
@@ -3406,8 +4642,8 @@ export {
|
|
|
3406
4642
|
collabList,
|
|
3407
4643
|
collabListMembers,
|
|
3408
4644
|
collabListMergeRequests,
|
|
4645
|
+
collabReAnchor,
|
|
3409
4646
|
collabReconcile,
|
|
3410
|
-
collabRecordTurn,
|
|
3411
4647
|
collabRecordingPreflight,
|
|
3412
4648
|
collabReject,
|
|
3413
4649
|
collabRemix,
|
|
@@ -3417,6 +4653,8 @@ export {
|
|
|
3417
4653
|
collabSyncUpstream,
|
|
3418
4654
|
collabUpdateMemberRole,
|
|
3419
4655
|
collabView,
|
|
4656
|
+
drainPendingFinalizeQueue,
|
|
3420
4657
|
getMemberRolesForScope,
|
|
4658
|
+
processPendingFinalizeJob,
|
|
3421
4659
|
validateMemberRole
|
|
3422
4660
|
};
|