@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/collab.js CHANGED
@@ -5,43 +5,32 @@ import {
5
5
  reserveAvailableDirPath,
6
6
  reserveDirectory,
7
7
  writeCollabBinding,
8
- writeCollabBindingSnapshot
9
- } from "./chunk-IXWQWFYT.js";
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
- summarizeUnifiedDiff,
42
- validateUnifiedDiff,
43
- writeTempUnifiedDiffBackup
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/application/collab/shared.ts
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 createHash("sha256").update(JSON.stringify(parts)).digest("hex");
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/recordingPreflight.ts
571
- async function collabRecordingPreflight(params) {
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
- const message = error instanceof Error ? error.message : "Not inside a git repository.";
577
- return {
578
- status: "not_git_repo",
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
- const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api });
1219
+ detected.repoRoot = repoRoot;
1220
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api ?? void 0 });
595
1221
  if (bindingResolution.status === "not_bound") {
596
- return {
597
- status: "not_bound",
598
- repoRoot,
599
- appId: null,
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
- return {
615
- status: "branch_binding_missing",
616
- repoRoot,
617
- appId: null,
618
- currentBranch: bindingResolution.currentBranch,
619
- branchName: bindingResolution.currentBranch,
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
- return {
633
- status: "family_ambiguous",
634
- repoRoot,
635
- appId: null,
636
- currentBranch: bindingResolution.currentBranch,
637
- branchName: bindingResolution.currentBranch,
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
- return {
651
- status: "metadata_conflict",
652
- repoRoot,
653
- appId: bindingResolution.binding.currentAppId,
654
- currentBranch: bindingResolution.currentBranch,
655
- branchName: bindingResolution.binding.branchName,
656
- headCommitHash: null,
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
- const [currentBranch, headCommitHash, worktreeStatus] = await Promise.all([
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
- const branchName = binding.branchName ?? null;
674
- if (!headCommitHash) {
675
- return {
676
- status: "missing_head",
677
- repoRoot,
678
- appId: binding.currentAppId,
679
- currentBranch,
680
- branchName,
681
- headCommitHash: null,
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
- return {
694
- status: "branch_mismatch",
695
- repoRoot,
696
- appId: binding.currentAppId,
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
- const syncResp = await params.api.syncLocalApp(binding.currentAppId, {
714
- baseCommitHash: headCommitHash,
715
- repoFingerprint: binding.repoFingerprint ?? void 0,
716
- remoteUrl: binding.remoteUrl ?? void 0,
717
- defaultBranch: binding.defaultBranch ?? void 0,
718
- dryRun: true
719
- });
720
- const sync = unwrapResponseObject(syncResp, "sync result");
721
- if (sync.status === "conflict_risk") {
722
- return {
723
- status: "metadata_conflict",
724
- repoRoot,
725
- appId: binding.currentAppId,
726
- currentBranch,
727
- branchName,
728
- headCommitHash,
729
- worktreeClean: worktreeStatus.isClean,
730
- syncStatus: sync.status,
731
- syncTargetCommitHash: sync.targetCommitHash,
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 raw = await fs.readFile(ownerPath, "utf8");
850
- const parsed = JSON.parse(raw);
851
- if (!parsed || typeof parsed !== "object") return null;
852
- if (!parsed.operation || !parsed.repoRoot || typeof parsed.pid !== "number" || !parsed.startedAt || !parsed.heartbeatAt) {
853
- return null;
854
- }
855
- return {
856
- operation: parsed.operation,
857
- repoRoot: parsed.repoRoot,
858
- pid: parsed.pid,
859
- hostname: typeof parsed.hostname === "string" ? parsed.hostname : "unknown",
860
- startedAt: parsed.startedAt,
861
- heartbeatAt: parsed.heartbeatAt,
862
- version: typeof parsed.version === "string" ? parsed.version : "unknown",
863
- requestId: typeof parsed.requestId === "string" ? parsed.requestId : null
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
- } catch {
866
- return null;
867
- }
868
- }
869
- async function isProcessAlive(owner) {
870
- if (!owner) return null;
871
- if (owner.hostname !== os.hostname()) return null;
872
- try {
873
- process.kill(owner.pid, 0);
874
- return true;
875
- } catch (error) {
876
- if (error?.code === "EPERM") return true;
877
- if (error?.code === "ESRCH") return false;
878
- return null;
879
- }
880
- }
881
- async function getLastKnownUpdateMs(lockDir, ownerPath, owner) {
882
- const heartbeatMs = owner ? Date.parse(owner.heartbeatAt) : Number.NaN;
883
- if (Number.isFinite(heartbeatMs)) return heartbeatMs;
884
- const startedMs = owner ? Date.parse(owner.startedAt) : Number.NaN;
885
- if (Number.isFinite(startedMs)) return startedMs;
886
- const stat = await fs.stat(ownerPath).catch(() => null);
887
- if (stat) return stat.mtimeMs;
888
- const dirStat = await fs.stat(lockDir).catch(() => null);
889
- if (dirStat) return dirStat.mtimeMs;
890
- return 0;
891
- }
892
- async function ensureLockDir(lockDir) {
893
- await fs.mkdir(path.dirname(lockDir), { recursive: true });
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
- return true;
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
- if (error?.code === "EEXIST") return false;
908
- throw error;
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
- function formatLockHint(params) {
912
- const lines = [
913
- params.observedHeldLock ? `Observed lock state: ${REMIX_ERROR_CODES.REPO_LOCK_HELD}.` : null,
914
- params.owner ? `Active operation: ${params.owner.operation}` : "Active operation: unknown",
915
- params.owner ? `Repo root: ${params.owner.repoRoot}` : null,
916
- params.owner ? `Owner: pid=${params.owner.pid} host=${params.owner.hostname}` : null,
917
- params.owner ? `Started at: ${params.owner.startedAt}` : null,
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 formatOwnerSummary(owner) {
926
- if (!owner) {
927
- return "unknown owner";
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 buildStaleRecoveryNotice(owner) {
932
- return {
933
- code: REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED,
934
- owner,
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
- async function acquirePhysicalLock(lockDir, ownerPath, owner, options) {
939
- const startedAt = Date.now();
940
- const notices = [];
941
- let observedHeldLock = false;
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 startHeartbeat(lockDir, ownerPath, owner, heartbeatMs) {
969
- return setInterval(() => {
970
- const nextOwner = {
971
- ...owner,
972
- heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
973
- };
974
- owner.heartbeatAt = nextOwner.heartbeatAt;
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
- async function releaseReentrantLock(lockDir) {
980
- const held = heldLocks.get(lockDir);
981
- if (!held) return;
982
- held.count -= 1;
983
- if (held.count > 0) return;
984
- clearInterval(held.heartbeatTimer);
985
- heldLocks.delete(lockDir);
986
- await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
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 withRepoMutationLock(options, fn) {
989
- const repoRoot = await findGitRoot(options.cwd);
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
- return await fn({
1020
- repoRoot,
1021
- gitCommonDir,
1022
- lockDir,
1023
- notices,
1024
- warnings: notices.map((notice) => notice.message)
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
- } finally {
1027
- await releaseReentrantLock(lockDir);
1028
- }
1029
- }
1030
-
1031
- // src/application/collab/collabSync.ts
1032
- import fs2 from "fs/promises";
1033
- import os2 from "os";
1034
- import path2 from "path";
1035
- async function collabSync(params) {
1036
- const repoRoot = await findGitRoot(params.cwd);
1037
- const binding = await ensureActiveLaneBinding({
1038
- repoRoot,
1039
- api: params.api,
1040
- operation: "`remix collab sync`"
1041
- });
1042
- if (!binding) {
1043
- throw new RemixError("Repository is not bound to Remix.", {
1044
- exitCode: 2,
1045
- hint: "Run `remix collab init` first."
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
- await ensureCleanWorktree(repoRoot);
1049
- const branch = await requireCurrentBranch(repoRoot);
1050
- assertBoundBranchMatch({
1051
- currentBranch: branch,
1052
- branchName: binding.branchName,
1053
- allowBranchMismatch: params.allowBranchMismatch,
1054
- operation: "`remix collab sync`"
1055
- });
1056
- const headCommitHash = await getHeadCommitHash(repoRoot);
1057
- const repoSnapshot = await captureRepoSnapshot(repoRoot);
1058
- if (!headCommitHash) {
1059
- throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
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
- if (sync.status === "base_unknown") {
1076
- throw new RemixError("Local repository cannot be fast-forward synced.", {
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
- status: sync.status,
1084
- branch,
1085
- repoRoot,
1086
- baseCommitHash: sync.baseCommitHash,
1087
- targetCommitHash: sync.targetCommitHash,
1088
- targetCommitId: sync.targetCommitId,
1089
- stats: sync.stats,
1090
- localCommitHash: headCommitHash,
1091
- applied: false,
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
- const previewResult = {
1096
- status: sync.status,
1097
- branch,
1098
- repoRoot,
1099
- baseCommitHash: sync.baseCommitHash,
1100
- targetCommitHash: sync.targetCommitHash,
1101
- targetCommitId: sync.targetCommitId,
1102
- stats: sync.stats,
1103
- bundleRef: sync.bundleRef,
1104
- bundleSizeBytes: sync.bundleSizeBytes,
1105
- localCommitHash: headCommitHash,
1106
- applied: false,
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
- await ensureCleanWorktree(lockedRepoRoot);
1128
- const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
1129
- assertBoundBranchMatch({
1130
- currentBranch: lockedBranch,
1131
- branchName: binding.branchName,
1132
- allowBranchMismatch: params.allowBranchMismatch,
1133
- operation: "`remix collab sync`"
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
- const tempDir = await fs2.mkdtemp(path2.join(os2.tmpdir(), "remix-sync-"));
1136
- const bundlePath = path2.join(tempDir, "sync-local.bundle");
1137
- try {
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/collabAdd.ts
1157
- function assertSupportedRecordingPreflight(preflight) {
1158
- if (preflight.status === "not_bound") {
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: preflight.hint
1759
+ hint: "Run `remix collab init` first."
1162
1760
  });
1163
1761
  }
1164
- if (preflight.status === "branch_binding_missing") {
1165
- throw new RemixError("Current branch is not yet bound to a Remix lane.", {
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: preflight.hint
1769
+ hint: "Finalize turns now capture the real workspace boundary from the local snapshot store."
1168
1770
  });
1169
1771
  }
1170
- if (preflight.status === "family_ambiguous") {
1171
- throw new RemixError("Multiple canonical Remix families match this repository.", {
1172
- exitCode: 2,
1173
- hint: preflight.hint
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 (preflight.status === "not_git_repo") {
1177
- throw new RemixError(preflight.hint || "Not inside a git repository.", {
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: preflight.hint
1786
+ hint: detected.hint
1180
1787
  });
1181
1788
  }
1182
- if (preflight.status === "missing_head") {
1183
- throw new RemixError("Failed to resolve local HEAD commit.", {
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: preflight.hint
1792
+ hint: detected.hint
1186
1793
  });
1187
1794
  }
1188
- if (preflight.status === "branch_mismatch") {
1189
- assertBoundBranchMatch({
1190
- currentBranch: preflight.currentBranch,
1191
- branchName: preflight.branchName,
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 (preflight.status === "metadata_conflict") {
1197
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
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: preflight.hint
1804
+ hint: detected.hint
1200
1805
  });
1201
1806
  }
1202
- if (preflight.status === "reconcile_required") {
1203
- throw new RemixError("Local repository cannot be fast-forward synced.", {
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: preflight.hint
1815
+ hint: "Run `remix collab re-anchor` to create a fresh baseline."
1206
1816
  });
1207
1817
  }
1208
- }
1209
- async function collabAdd(params) {
1210
- const repoRoot = await findGitRoot(params.cwd);
1211
- const binding = await ensureActiveLaneBinding({
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
- operation: "`remix collab add`"
1874
+ cwd: params.cwd,
1875
+ allowBranchMismatch: params.allowBranchMismatch
1215
1876
  });
1216
- if (!binding) {
1217
- throw new RemixError("Repository is not bound to Remix.", {
1218
- exitCode: 2,
1219
- hint: "Run `remix collab init` first."
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 prompt = params.prompt.trim();
1223
- if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
1224
- const assistantResponse = params.assistantResponse?.trim() || null;
1225
- const attachWarnings = (value, warnings) => warnings.length > 0 ? { ...value, warnings } : value;
1226
- const diffSource = params.diffSource ?? (params.diff ? "external" : "worktree");
1227
- const autoSyncEnabled = params.sync !== false;
1228
- const run = async (lockWarnings = []) => {
1229
- const preflight = await collabRecordingPreflight({
1230
- api: params.api,
1231
- cwd: repoRoot,
1232
- allowBranchMismatch: params.allowBranchMismatch
1233
- });
1234
- assertSupportedRecordingPreflight(preflight);
1235
- const branch = preflight.currentBranch;
1236
- assertBoundBranchMatch({
1237
- currentBranch: branch,
1238
- branchName: binding.branchName,
1239
- allowBranchMismatch: params.allowBranchMismatch,
1240
- operation: "`remix collab add`"
1241
- });
1242
- let headCommitHash = preflight.headCommitHash;
1243
- if (!headCommitHash) {
1244
- throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
1245
- }
1246
- const worktreeStatus = await getWorktreeStatus(repoRoot);
1247
- if (preflight.status === "ready_to_fast_forward") {
1248
- if (!autoSyncEnabled) {
1249
- throw new RemixError("Local repository is stale and `collab add` sync automation is disabled.", {
1250
- exitCode: 2,
1251
- hint: "Run `remix collab sync` first, or rerun without disabling sync automation."
1252
- });
1253
- }
1254
- if (!worktreeStatus.isClean && diffSource !== "worktree") {
1255
- throw new RemixError("Automatic stale-work replay requires the current worktree diff.", {
1256
- exitCode: 2,
1257
- hint: "Use `remix collab add` without an external diff while the local repo is dirty, or clean the repo before submitting an external diff."
1258
- });
1259
- }
1260
- if (worktreeStatus.isClean) {
1261
- await collabSync({
1262
- api: params.api,
1263
- cwd: repoRoot,
1264
- dryRun: false,
1265
- allowBranchMismatch: params.allowBranchMismatch
1266
- });
1267
- headCommitHash = await getHeadCommitHash(repoRoot);
1268
- if (!headCommitHash) {
1269
- throw new RemixError("Failed to resolve local HEAD after syncing.", { exitCode: 1 });
1270
- }
1271
- } else {
1272
- const staleWorkSnapshot = await captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true });
1273
- const preserved = await preserveWorkspaceChanges(repoRoot, "remix-add-preserve");
1274
- try {
1275
- await assertRepoSnapshotUnchanged(repoRoot, staleWorkSnapshot, {
1276
- operation: "`remix collab add` stale-work pre-sync",
1277
- recoveryHint: "The worktree changed while local changes were being preserved. Review the local changes and rerun `remix collab add`."
1278
- });
1279
- await discardTrackedChanges(repoRoot, "`remix collab add`");
1280
- await discardCapturedUntrackedChanges(repoRoot, preserved.includedUntrackedPaths);
1281
- await collabSync({
1282
- api: params.api,
1283
- cwd: repoRoot,
1284
- dryRun: false,
1285
- allowBranchMismatch: params.allowBranchMismatch
1286
- });
1287
- } catch (err) {
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
- const { backupPath } = await writeTempUnifiedDiffBackup(diff, "remix-add");
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 pollAppReady(params.api, binding.currentAppId);
1438
- if (submissionSnapshot) {
1439
- await assertRepoSnapshotUnchanged(repoRoot, submissionSnapshot, {
1440
- operation: "`remix collab add` auto-sync",
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 attachWarnings(step, lockWarnings);
1466
- };
1467
- if (diffSource === "worktree") {
1468
- return withRepoMutationLock(
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
- // src/application/collab/collabRecordTurn.ts
1480
- function assertSupportedRecordingPreflight2(preflight) {
1481
- if (preflight.status === "not_bound") {
1482
- throw new RemixError("Repository is not bound to Remix.", {
1483
- exitCode: 2,
1484
- hint: preflight.hint
1485
- });
1486
- }
1487
- if (preflight.status === "branch_binding_missing") {
1488
- throw new RemixError("Current branch is not yet bound to a Remix lane.", {
1489
- exitCode: 2,
1490
- hint: preflight.hint
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
- if (preflight.status === "family_ambiguous") {
1494
- throw new RemixError("Multiple canonical Remix families match this repository.", {
1495
- exitCode: 2,
1496
- hint: preflight.hint
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
- if (preflight.status === "not_git_repo") {
1500
- throw new RemixError(preflight.hint || "Not inside a git repository.", {
1501
- exitCode: 2,
1502
- hint: preflight.hint
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
- if (preflight.status === "missing_head") {
1506
- throw new RemixError("Failed to resolve local HEAD commit.", {
1507
- exitCode: 1,
1508
- hint: preflight.hint
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
- if (preflight.status === "branch_mismatch") {
1512
- assertBoundBranchMatch({
1513
- currentBranch: preflight.currentBranch,
1514
- branchName: preflight.branchName,
1515
- allowBranchMismatch: false,
1516
- operation: "`remix collab record-turn`"
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
- if (preflight.status === "metadata_conflict") {
1520
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
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: preflight.hint
2149
+ hint: "Run `remix collab re-anchor` to create a fresh baseline before applying server changes."
1523
2150
  });
1524
2151
  }
1525
- if (preflight.status === "reconcile_required") {
1526
- throw new RemixError("Local repository cannot be fast-forward synced.", {
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: preflight.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
- async function collabRecordTurn(params) {
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 record-turn`"
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 prompt = params.prompt.trim();
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
- assertSupportedRecordingPreflight2(preflight);
1555
- if (!preflight.worktreeClean) {
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 (preflight.status === "ready_to_fast_forward") {
1562
- await collabSync({
1563
- api: params.api,
1564
- cwd: repoRoot,
1565
- dryRun: false,
1566
- allowBranchMismatch: params.allowBranchMismatch
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 getCurrentBranch(repoRoot);
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 idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
1578
- appId: binding.currentAppId,
1579
- upstreamAppId: binding.upstreamAppId,
1580
- headCommitHash,
1581
- prompt,
1582
- assistantResponse
1583
- });
1584
- const resp = await params.api.createCollabTurn(binding.currentAppId, {
1585
- threadId: binding.threadId ?? void 0,
1586
- collabLaneId: binding.laneId ?? void 0,
1587
- prompt,
1588
- assistantResponse,
1589
- actor: params.actor,
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
- remoteUrl: binding.remoteUrl,
1594
- defaultBranch: binding.defaultBranch,
1595
- headCommitHash
1596
- },
1597
- idempotencyKey
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 turn = unwrapResponseObject(resp, "collab turn");
1600
- return turn;
1601
- }
1602
-
1603
- // src/application/collab/collabFinalizeTurn.ts
1604
- function collectWarnings(value) {
1605
- if (!Array.isArray(value)) return [];
1606
- return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
1607
- }
1608
- async function collabFinalizeTurn(params) {
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
- api: params.api,
1613
- operation: "`remix collab finalize-turn`"
1614
- });
1615
- if (!binding) {
1616
- throw new RemixError("Repository is not bound to Remix.", {
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 init` first."
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
- const prompt = params.prompt.trim();
1622
- const assistantResponse = params.assistantResponse.trim();
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: "Pass a non-empty diff when using external diff submission, or omit the external diff so finalize-turn can inspect the live worktree."
2284
+ hint: delta.warnings.join("\n") || "Run `remix collab status` to inspect the current alignment state."
1644
2285
  });
1645
2286
  }
1646
- if (hasChangedTurn) {
1647
- const localHeadBefore = currentHeadCommitHash;
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
- prompt,
1653
- assistantResponse,
1654
- diff: diffSource === "external" ? externalDiff : null,
1655
- diffSource,
1656
- sync: params.sync,
1657
- allowBranchMismatch: params.allowBranchMismatch,
1658
- idempotencyKey,
1659
- actor: params.actor
1660
- });
1661
- const localHeadAfter = await getHeadCommitHash(repoRoot);
1662
- const warnings = [
1663
- ...collectWarnings(changeStep.warnings),
1664
- ...diffSource === "external" && params.sync !== false ? [
1665
- "Automatic local discard+sync was skipped because the diff came from an external source and may not match the current worktree."
1666
- ] : []
1667
- ];
1668
- const autoSyncRequested = params.sync !== false;
1669
- const autoSyncEligible = diffSource === "worktree";
1670
- return {
1671
- mode: "changed_turn",
1672
- idempotencyKey,
1673
- changeStep,
1674
- collabTurn: null,
1675
- autoSync: {
1676
- requested: autoSyncRequested,
1677
- eligible: autoSyncEligible,
1678
- attempted: autoSyncRequested && autoSyncEligible,
1679
- applied: autoSyncRequested && autoSyncEligible,
1680
- trackedChangesDiscarded: autoSyncRequested && autoSyncEligible,
1681
- capturedUntrackedPathsCandidate,
1682
- localHeadBefore,
1683
- localHeadAfter,
1684
- localRepoMutated: autoSyncRequested && autoSyncEligible && localHeadBefore !== localHeadAfter
1685
- },
1686
- warnings
1687
- };
1688
- }
1689
- const collabTurn = await collabRecordTurn({
1690
- api: params.api,
1691
- cwd: repoRoot,
1692
- prompt,
1693
- assistantResponse,
1694
- allowBranchMismatch: params.allowBranchMismatch,
1695
- idempotencyKey,
1696
- actor: params.actor
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 fs4 from "fs/promises";
1793
- import os3 from "os";
1794
- import path4 from "path";
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 fs4.access(targetPath);
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 fs4.stat(targetPath).catch(() => null);
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 = path4.resolve(startPath);
2437
+ let current = path6.resolve(startPath);
1809
2438
  while (true) {
1810
- if (await pathExists(path4.join(current, ".git"))) return current;
1811
- const parent = path4.dirname(current);
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 = path4.relative(parentPath, candidatePath);
1818
- return relative === "" || !relative.startsWith("..") && !path4.isAbsolute(relative);
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 = path4.resolve(params.outputDir.trim());
1823
- const parentDir2 = path4.dirname(preferredRepoRoot);
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 = path4.resolve(params.cwd);
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: path4.join(parentDir, params.defaultDirName),
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 fs4.mkdtemp(path4.join(os3.tmpdir(), "remix-checkout-"));
1879
- const bundlePath = path4.join(bundleTempDir, "repository.bundle");
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 fs4.writeFile(bundlePath, bundle.data);
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 fs4.rm(repoRoot, { recursive: true, force: true }).catch(() => {
2521
+ await fs5.rm(repoRoot, { recursive: true, force: true }).catch(() => {
1890
2522
  });
1891
2523
  throw err;
1892
2524
  } finally {
1893
- await fs4.rm(bundleTempDir, { recursive: true, force: true });
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: authoritativeLane?.currentAppId ?? String(app.id),
1964
- dashboardUrl: buildDashboardAppUrl(authoritativeLane?.currentAppId ?? String(app.id)),
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 fs7 from "fs/promises";
2144
- import path5 from "path";
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 fs5 from "fs";
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 = fs5.createReadStream(filePath);
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 fs6 from "fs";
2818
+ import fs7 from "fs";
2162
2819
  import { PassThrough } from "stream";
2163
2820
  async function uploadPresigned(params) {
2164
- const stats = await fs6.promises.stat(params.filePath).catch(() => null);
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 = fs6.createReadStream(params.filePath);
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 remoteUrl = normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
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 repoFingerprint = await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch });
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: path5.join(repoRoot, ".remix", "config.json"),
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 fs7.stat(bundlePath)).size;
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() || path5.basename(repoRoot),
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
- apps: page.items,
2715
- pagination: page.pagination
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 fs8 from "fs/promises";
2721
- import os4 from "os";
2722
- import path6 from "path";
2723
- async function collabReconcile(params) {
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
- assertBoundBranchMatch({
2739
- currentBranch: branch,
2740
- branchName: binding.branchName,
2741
- allowBranchMismatch: params.allowBranchMismatch,
2742
- operation: "`remix collab reconcile`"
3887
+ const baseline = await readLocalBaseline({
3888
+ repoFingerprint: binding.repoFingerprint,
3889
+ laneId: binding.laneId,
3890
+ repoRoot
2743
3891
  });
2744
- const headCommitHash = await getHeadCommitHash(repoRoot);
2745
- const repoSnapshot = await captureRepoSnapshot(repoRoot);
2746
- if (!headCommitHash) {
2747
- throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
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 syncResp = await params.api.syncLocalApp(binding.currentAppId, {
2750
- baseCommitHash: headCommitHash,
2751
- repoFingerprint: binding.repoFingerprint ?? void 0,
2752
- remoteUrl: binding.remoteUrl ?? void 0,
2753
- defaultBranch: binding.defaultBranch ?? void 0,
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 sync = unwrapResponseObject(syncResp, "sync result");
2757
- if (sync.status === "conflict_risk") {
2758
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
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: sync.warnings.join("\n") || "Run the command from the correct bound repository."
3911
+ hint: "Pull the server delta locally, or make local changes before running `remix collab reconcile`."
2761
3912
  });
2762
3913
  }
2763
- if (sync.status !== "base_unknown") {
2764
- return collabSync(params);
2765
- }
2766
- const preflightResp = await params.api.preflightAppReconcile(binding.currentAppId, {
2767
- localHeadCommitHash: headCommitHash,
2768
- repoFingerprint: binding.repoFingerprint ?? void 0,
2769
- remoteUrl: binding.remoteUrl ?? void 0,
2770
- defaultBranch: binding.defaultBranch ?? void 0
2771
- });
2772
- const preflight = unwrapResponseObject(preflightResp, "reconcile preflight");
2773
- if (preflight.status === "metadata_conflict") {
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: preflight.warnings.join("\n") || "Run the command from the correct bound repository."
3929
+ hint: delta.warnings.join("\n") || "Run the command from the correct bound repository."
2777
3930
  });
2778
3931
  }
2779
- if (preflight.status === "up_to_date") {
2780
- return collabSync(params);
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
- const previewResult = {
2783
- status: preflight.status,
2784
- branch,
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
- localHeadCommitHash: headCommitHash,
2787
- targetHeadCommitId: preflight.targetHeadCommitId,
2788
- targetHeadCommitHash: preflight.targetHeadCommitHash,
2789
- warnings: preflight.warnings,
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 previewResult;
3959
+ return preview;
2795
3960
  }
2796
- const { bundlePath, headCommitHash: bundledHeadCommitHash } = await createGitBundle(repoRoot, "reconcile-local.bundle");
2797
- const bundleTempDir = path6.dirname(bundlePath);
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 bundleStat = await fs8.stat(bundlePath);
2800
- const checksumSha256 = await sha256FileHex(bundlePath);
2801
- const presignResp = await params.api.presignImportUploadFirstParty({
2802
- file: {
2803
- name: path6.basename(bundlePath),
2804
- mimeType: "application/x-git-bundle",
2805
- size: bundleStat.size,
2806
- checksumSha256
2807
- }
2808
- });
2809
- const uploadTarget = unwrapResponseObject(presignResp, "reconcile upload target");
2810
- await uploadPresigned({
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
- const startResp = await params.api.startAppReconcile(binding.currentAppId, {
2816
- uploadId: String(uploadTarget.uploadId),
2817
- localHeadCommitHash: bundledHeadCommitHash,
2818
- repoFingerprint: binding.repoFingerprint ?? void 0,
2819
- remoteUrl: binding.remoteUrl ?? void 0,
2820
- defaultBranch: binding.defaultBranch ?? void 0,
2821
- idempotencyKey: buildDeterministicIdempotencyKey({
2822
- appId: binding.currentAppId,
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
- const started = unwrapResponseObject(startResp, "reconcile");
2828
- const reconcile = await pollReconcile(params.api, binding.currentAppId, started.id);
2829
- if (!reconcile.mergeBaseCommitHash || !reconcile.reconciledHeadCommitHash || !reconcile.resultBundleRef) {
2830
- throw new RemixError("Reconcile completed without enough result metadata to update the local repository.", {
2831
- exitCode: 1
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
- const mergeBaseCommitHash = reconcile.mergeBaseCommitHash;
2835
- const reconciledHeadCommitHash = reconcile.reconciledHeadCommitHash;
2836
- const resultBundleRef = reconcile.resultBundleRef;
2837
- return withRepoMutationLock(
2838
- {
2839
- cwd: repoRoot,
2840
- operation: "collabReconcile"
2841
- },
2842
- async ({ repoRoot: lockedRepoRoot, warnings }) => {
2843
- await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
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
- } finally {
2891
- await fs8.rm(bundleTempDir, { recursive: true, force: true });
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: authoritativeLane?.currentAppId ?? String(app.id),
2966
- dashboardUrl: buildDashboardAppUrl(authoritativeLane?.currentAppId ?? String(app.id)),
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, initialHeadCommitHash, worktreeStatus, bindingResolution] = await Promise.all([
4368
+ const [branch, headCommitHash, worktreeStatus, detected] = await Promise.all([
3083
4369
  getCurrentBranch(repoRoot),
3084
4370
  getHeadCommitHash(repoRoot),
3085
4371
  getWorktreeStatus(repoRoot),
3086
- resolveActiveLaneBinding({ repoRoot, api: params.api ?? void 0 })
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 (bindingResolution.status === "not_bound") {
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: null,
3136
- currentAppId: null,
3137
- upstreamAppId: null,
3138
- isRemix: null,
3139
- threadId: null,
3140
- repoFingerprint: bindingResolution.repoFingerprint,
3141
- remoteUrl: bindingResolution.remoteUrl,
3142
- defaultBranch: bindingResolution.defaultBranch,
3143
- laneId: null,
3144
- branchName: bindingResolution.currentBranch,
3145
- bindingMode: null
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
- addBlockedReason(status.sync, "family_ambiguous");
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
- const binding = bindingResolution.binding;
3157
- status.binding = {
3158
- isBound: true,
3159
- path: getCollabBindingPath(repoRoot),
3160
- projectId: binding.projectId,
3161
- currentAppId: binding.currentAppId,
3162
- upstreamAppId: binding.upstreamAppId,
3163
- isRemix: binding.currentAppId !== binding.upstreamAppId,
3164
- threadId: binding.threadId,
3165
- repoFingerprint: binding.repoFingerprint,
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.repo.branchMismatch = !isBoundBranchMatch(branch, binding.branchName);
3173
- if (status.repo.branchMismatch) {
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
- `Current branch ${branch ?? "(detached)"} does not match bound branch ${binding.branchName ?? "(unset)"}.`
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 (bindingResolution.status === "binding_conflict") {
4490
+ if (detected.pendingFinalize.failedJobCount > 0) {
3180
4491
  addWarning(
3181
4492
  status,
3182
- `Local binding app ${binding.currentAppId} conflicts with server-resolved app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"} for branch ${bindingResolution.currentBranch ?? "(detached)"}.`
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 (!params.api) {
3186
- status.recommendedAction = "no_action";
3187
- return status;
3188
- }
3189
- const [appResult, incomingResult, outgoingResult, syncResult] = await Promise.allSettled([
3190
- params.api.getApp(binding.currentAppId),
3191
- params.api.listMergeRequests({ queue: "app_reviewable", appId: binding.currentAppId, status: "open", kind: "merge" }),
3192
- params.api.listMergeRequests({ queue: "app_outgoing", appId: binding.currentAppId, status: "open", kind: "merge" }),
3193
- headCommitHash ? params.api.syncLocalApp(binding.currentAppId, {
3194
- baseCommitHash: headCommitHash,
3195
- repoFingerprint: binding.repoFingerprint ?? void 0,
3196
- remoteUrl: binding.remoteUrl ?? void 0,
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
- } else {
3290
- status.sync.error = formatCliErrorDetail(syncResult.reason) ?? "Failed to fetch sync status.";
3291
- addBlockedReason(status.sync, "remote_error");
3292
- addBlockedReason(status.reconcile, "remote_error");
3293
- addWarning(status, status.sync.error);
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 (!status.binding.isBound) {
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 ensureCleanWorktree(repoRoot, "`remix collab sync-upstream`");
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 ensureCleanWorktree(lockedRepoRoot, "`remix collab sync-upstream`");
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
  };