@remixhq/core 0.1.24 → 0.1.26

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
@@ -8,7 +8,7 @@ import {
8
8
  writeCollabBinding,
9
9
  writeCollabBindingSnapshot,
10
10
  writeJsonAtomic
11
- } from "./chunk-P4XIICU3.js";
11
+ } from "./chunk-GNW6FA4J.js";
12
12
  import {
13
13
  applyUnifiedDiffToWorktree,
14
14
  assertRepoSnapshotUnchanged,
@@ -17,6 +17,8 @@ import {
17
17
  checkoutLocalBranch,
18
18
  cloneGitBundleToDirectory,
19
19
  createGitBundle,
20
+ createGitCommandPlan,
21
+ createSystemGitAdapter,
20
22
  ensureCleanWorktree,
21
23
  ensureGitInfoExcludeEntries,
22
24
  findGitRoot,
@@ -30,7 +32,7 @@ import {
30
32
  requireCurrentBranch,
31
33
  setRemoteOriginUrl,
32
34
  summarizeUnifiedDiff
33
- } from "./chunk-S4ECO35X.js";
35
+ } from "./chunk-FA6PTZQI.js";
34
36
  import {
35
37
  REMIX_ERROR_CODES
36
38
  } from "./chunk-GC2MOT3U.js";
@@ -191,6 +193,9 @@ function getAsyncJobDir(jobId) {
191
193
  function getAsyncJobFilePath(jobId) {
192
194
  return path.join(getAsyncJobDir(jobId), "job.json");
193
195
  }
196
+ function getAsyncJobBundlePath(jobId) {
197
+ return path.join(getAsyncJobDir(jobId), "bundle.bundle");
198
+ }
194
199
  function getLogsRoot() {
195
200
  return path.join(getCollabStateRoot(), "logs");
196
201
  }
@@ -204,6 +209,7 @@ function getDrainerPidPath() {
204
209
  // src/infrastructure/collab/asyncJobStore.ts
205
210
  var ASYNC_JOB_LOCK_STALE_MS = 10 * 60 * 1e3;
206
211
  var TERMINAL_ASYNC_JOB_RETENTION_MS = 24 * 60 * 60 * 1e3;
212
+ var REMOVED_ASYNC_JOB_KINDS = /* @__PURE__ */ new Set([["re", "anchor"].join("_")]);
207
213
  function getJobLockPath(id) {
208
214
  return path2.join(getAsyncJobDir(id), "lock");
209
215
  }
@@ -249,7 +255,7 @@ function normalizeJob(input) {
249
255
  if (input.kind === "init_post") {
250
256
  return { ...base, kind: "init_post", payload: input.payload };
251
257
  }
252
- return { ...base, kind: "re_anchor", payload: input.payload };
258
+ throw new Error(`Unsupported async job kind: ${input.kind ?? "unknown"}`);
253
259
  }
254
260
  async function enqueueAsyncJob(input) {
255
261
  const job = normalizeJob(input);
@@ -262,7 +268,7 @@ function parseAsyncJob(raw) {
262
268
  const data = raw;
263
269
  if (data.schemaVersion !== 1) return null;
264
270
  if (typeof data.id !== "string") return null;
265
- if (data.kind !== "init" && data.kind !== "init_post" && data.kind !== "re_anchor") return null;
271
+ if (data.kind !== "init" && data.kind !== "init_post") return null;
266
272
  if (!data.payload || typeof data.payload !== "object") return null;
267
273
  return data;
268
274
  }
@@ -289,6 +295,30 @@ async function listAsyncJobs() {
289
295
  );
290
296
  return jobs.filter((job) => Boolean(job)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
291
297
  }
298
+ async function pruneRemovedAsyncJobKinds() {
299
+ const root = getAsyncJobsRoot();
300
+ let entries;
301
+ try {
302
+ entries = await fs.readdir(root, { withFileTypes: true });
303
+ } catch (error) {
304
+ if (error?.code === "ENOENT") return;
305
+ throw error;
306
+ }
307
+ await Promise.all(
308
+ entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
309
+ let parsed;
310
+ try {
311
+ const raw = await fs.readFile(getAsyncJobFilePath(entry.name), "utf8");
312
+ parsed = JSON.parse(raw);
313
+ } catch {
314
+ return;
315
+ }
316
+ if (parsed.schemaVersion === 1 && typeof parsed.id === "string" && typeof parsed.kind === "string" && REMOVED_ASYNC_JOB_KINDS.has(parsed.kind)) {
317
+ await deleteAsyncJob(entry.name);
318
+ }
319
+ })
320
+ );
321
+ }
292
322
  async function listAsyncJobsForRepo(params) {
293
323
  const jobs = await listAsyncJobs();
294
324
  return jobs.filter((job) => {
@@ -319,8 +349,8 @@ async function updateAsyncJob(jobId, update) {
319
349
  if (!existing) return null;
320
350
  const now = (/* @__PURE__ */ new Date()).toISOString();
321
351
  if (existing.kind === "init") {
322
- const nextPayload2 = update.payload ?? existing.payload;
323
- const next2 = {
352
+ const nextPayload = update.payload ?? existing.payload;
353
+ const next = {
324
354
  ...existing,
325
355
  ...update,
326
356
  schemaVersion: 1,
@@ -328,14 +358,14 @@ async function updateAsyncJob(jobId, update) {
328
358
  kind: "init",
329
359
  createdAt: existing.createdAt,
330
360
  updatedAt: now,
331
- payload: nextPayload2
361
+ payload: nextPayload
332
362
  };
333
- await writeJsonAtomic(getAsyncJobFilePath(jobId), next2);
334
- return next2;
363
+ await writeJsonAtomic(getAsyncJobFilePath(jobId), next);
364
+ return next;
335
365
  }
336
366
  if (existing.kind === "init_post") {
337
- const nextPayload2 = update.payload ?? existing.payload;
338
- const next2 = {
367
+ const nextPayload = update.payload ?? existing.payload;
368
+ const next = {
339
369
  ...existing,
340
370
  ...update,
341
371
  schemaVersion: 1,
@@ -343,24 +373,12 @@ async function updateAsyncJob(jobId, update) {
343
373
  kind: "init_post",
344
374
  createdAt: existing.createdAt,
345
375
  updatedAt: now,
346
- payload: nextPayload2
376
+ payload: nextPayload
347
377
  };
348
- await writeJsonAtomic(getAsyncJobFilePath(jobId), next2);
349
- return next2;
378
+ await writeJsonAtomic(getAsyncJobFilePath(jobId), next);
379
+ return next;
350
380
  }
351
- const nextPayload = update.payload ?? existing.payload;
352
- const next = {
353
- ...existing,
354
- ...update,
355
- schemaVersion: 1,
356
- id: existing.id,
357
- kind: "re_anchor",
358
- createdAt: existing.createdAt,
359
- updatedAt: now,
360
- payload: nextPayload
361
- };
362
- await writeJsonAtomic(getAsyncJobFilePath(jobId), next);
363
- return next;
381
+ throw new Error(`Unsupported async job kind: ${existing.kind ?? "unknown"}`);
364
382
  }
365
383
  async function deleteAsyncJob(jobId) {
366
384
  await fs.rm(getAsyncJobDir(jobId), { recursive: true, force: true });
@@ -434,14 +452,15 @@ async function summarizeAsyncJobs(params) {
434
452
  }
435
453
  summary.activeJobCount = summary.queuedJobCount + summary.processingJobCount;
436
454
  summary.kinds = Array.from(kinds);
437
- summary.oldestCreatedAt = active[0]?.createdAt ?? null;
438
- summary.newestCreatedAt = active[active.length - 1]?.createdAt ?? null;
455
+ summary.oldestCreatedAt = active[0].createdAt;
456
+ summary.newestCreatedAt = active[active.length - 1].createdAt;
439
457
  if (summary.processingJobCount > 0) summary.state = "processing";
440
458
  else if (summary.queuedJobCount > 0) summary.state = "queued";
441
459
  else summary.state = "failed";
442
460
  return summary;
443
461
  }
444
462
  async function pruneTerminalAsyncJobs() {
463
+ await pruneRemovedAsyncJobKinds();
445
464
  const jobs = await listAsyncJobs();
446
465
  const now = Date.now();
447
466
  await Promise.all(
@@ -453,6 +472,9 @@ async function pruneTerminalAsyncJobs() {
453
472
  }).map((job) => deleteAsyncJob(job.id))
454
473
  );
455
474
  }
475
+ function getAsyncJobBundleFilePath(jobId) {
476
+ return getAsyncJobBundlePath(jobId);
477
+ }
456
478
  async function awaitAsyncJob(params) {
457
479
  const pollIntervalMs = Math.max(50, params.pollIntervalMs ?? 500);
458
480
  const deadline = Date.now() + params.timeoutMs;
@@ -536,6 +558,15 @@ async function writeLocalBaseline(baseline) {
536
558
  await writeJsonAtomic(getBaselinePath(baseline), normalized);
537
559
  return normalized;
538
560
  }
561
+ async function clearLocalBaseline(params) {
562
+ try {
563
+ await fs2.unlink(getBaselinePath(params));
564
+ } catch (error) {
565
+ if (error?.code !== "ENOENT") {
566
+ throw error;
567
+ }
568
+ }
569
+ }
539
570
 
540
571
  // src/infrastructure/collab/localSnapshotStore.ts
541
572
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
@@ -1207,7 +1238,9 @@ async function resolveProjectLaneIfAuthoritative(api, params) {
1207
1238
  repoFingerprint: params.repoFingerprint ?? void 0,
1208
1239
  remoteUrl: params.remoteUrl ?? void 0,
1209
1240
  defaultBranch: params.defaultBranch ?? void 0,
1210
- branchName
1241
+ branchName,
1242
+ currentAppId: params.currentAppId ?? void 0,
1243
+ upstreamAppId: params.expectedUpstreamAppId ?? void 0
1211
1244
  });
1212
1245
  return unwrapResponseObject(laneResp, "project lane binding");
1213
1246
  };
@@ -1222,6 +1255,8 @@ async function resolveProjectLaneIfAuthoritative(api, params) {
1222
1255
  remoteUrl: params.remoteUrl ?? void 0,
1223
1256
  defaultBranch: params.defaultBranch ?? void 0,
1224
1257
  branchName,
1258
+ currentAppId: params.currentAppId ?? void 0,
1259
+ upstreamAppId: params.expectedUpstreamAppId ?? void 0,
1225
1260
  seedAppId: params.seedAppId
1226
1261
  });
1227
1262
  lane = unwrapResponseObject(ensuredResp, "project lane binding");
@@ -1233,6 +1268,8 @@ async function resolveProjectLaneIfAuthoritative(api, params) {
1233
1268
  remoteUrl: params.remoteUrl ?? void 0,
1234
1269
  defaultBranch: params.defaultBranch ?? void 0,
1235
1270
  branchName,
1271
+ currentAppId: params.currentAppId ?? void 0,
1272
+ upstreamAppId: params.expectedUpstreamAppId ?? void 0,
1236
1273
  seedAppId: params.seedAppId
1237
1274
  });
1238
1275
  lane = unwrapResponseObject(ensuredResp, "project lane binding");
@@ -1243,10 +1280,12 @@ async function resolveProjectLaneIfAuthoritative(api, params) {
1243
1280
  if (params.expectedUpstreamAppId && lane.upstreamAppId && lane.upstreamAppId !== params.expectedUpstreamAppId) return null;
1244
1281
  return lane;
1245
1282
  }
1246
- async function pollAppReady(api, appId) {
1247
- const started = Date.now();
1283
+ async function pollAppReady(api, appId, deps = {}) {
1284
+ const now = deps.clock?.now ?? Date.now;
1285
+ const sleepFn = deps.timers?.sleep ?? sleep;
1286
+ const started = now();
1248
1287
  let delay = 2e3;
1249
- while (Date.now() - started < 20 * 60 * 1e3) {
1288
+ while (now() - started < 20 * 60 * 1e3) {
1250
1289
  const appResp = await api.getApp(appId);
1251
1290
  const app = unwrapResponseObject(appResp, "app");
1252
1291
  const status = typeof app.status === "string" ? app.status : "";
@@ -1257,7 +1296,7 @@ async function pollAppReady(api, appId) {
1257
1296
  hint: typeof app.statusError === "string" ? app.statusError : null
1258
1297
  });
1259
1298
  }
1260
- await sleep(delay);
1299
+ await sleepFn(delay);
1261
1300
  delay = Math.min(1e4, Math.floor(delay * 1.4));
1262
1301
  }
1263
1302
  throw new RemixError("Timed out waiting for app to become ready.", { exitCode: 1 });
@@ -1327,30 +1366,6 @@ async function pollChangeStep(api, appId, changeStepId) {
1327
1366
  }
1328
1367
  throw new RemixError("Timed out waiting for change step.", { exitCode: 1 });
1329
1368
  }
1330
- async function pollReconcile(api, appId, reconcileId) {
1331
- const started = Date.now();
1332
- let delay = 1500;
1333
- while (Date.now() - started < 30 * 60 * 1e3) {
1334
- const resp = await api.getAppReconcile(appId, reconcileId);
1335
- const reconcile = unwrapResponseObject(resp, "reconcile");
1336
- if (reconcile.status === "succeeded") return reconcile;
1337
- if (reconcile.status === "manual_reconcile_required") {
1338
- throw new RemixError("Reconciliation requires manual intervention.", {
1339
- exitCode: 2,
1340
- hint: reconcile.statusError || "The server could not safely replay the local-only commits."
1341
- });
1342
- }
1343
- if (reconcile.status === "failed" || reconcile.status === "cancelled") {
1344
- throw new RemixError("Reconciliation failed.", {
1345
- exitCode: 1,
1346
- hint: reconcile.statusError || null
1347
- });
1348
- }
1349
- await sleep(delay);
1350
- delay = Math.min(1e4, Math.floor(delay * 1.4));
1351
- }
1352
- throw new RemixError("Timed out waiting for reconcile job.", { exitCode: 1 });
1353
- }
1354
1369
  async function pollChangeStepReplay(api, appId, replayId) {
1355
1370
  const started = Date.now();
1356
1371
  let delay = 1500;
@@ -1507,7 +1522,14 @@ var RESOLUTION_CACHE_TTL_MS = 5e3;
1507
1522
  var resolutionCache = /* @__PURE__ */ new Map();
1508
1523
  var cacheClock3 = () => Date.now();
1509
1524
  function buildResolutionCacheKey(params) {
1510
- return `${params.repoRoot}|${params.currentBranch ?? ""}|${params.hasApi ? "1" : "0"}`;
1525
+ return [
1526
+ params.repoRoot,
1527
+ params.currentBranch ?? "",
1528
+ params.hasApi ? "1" : "0",
1529
+ params.currentAppId ?? "",
1530
+ params.upstreamAppId ?? "",
1531
+ params.laneId ?? ""
1532
+ ].join("|");
1511
1533
  }
1512
1534
  function readResolutionCache(key) {
1513
1535
  const entry = resolutionCache.get(key);
@@ -1527,7 +1549,10 @@ async function resolveActiveLaneBinding(params) {
1527
1549
  const cacheKey = buildResolutionCacheKey({
1528
1550
  repoRoot: params.repoRoot,
1529
1551
  currentBranch: state ? normalizeBranchName(state.currentBranch) : null,
1530
- hasApi: Boolean(params.api)
1552
+ hasApi: Boolean(params.api),
1553
+ currentAppId: state?.binding?.currentAppId ?? null,
1554
+ upstreamAppId: state?.binding?.upstreamAppId ?? null,
1555
+ laneId: state?.binding?.laneId ?? null
1531
1556
  });
1532
1557
  const cached = readResolutionCache(cacheKey);
1533
1558
  if (cached) return cached;
@@ -1567,7 +1592,9 @@ async function resolveActiveLaneBindingUncached(params, state) {
1567
1592
  repoFingerprint: state.repoFingerprint ?? void 0,
1568
1593
  remoteUrl: state.remoteUrl ?? void 0,
1569
1594
  defaultBranch: state.defaultBranch ?? void 0,
1570
- branchName: currentBranch
1595
+ branchName: currentBranch,
1596
+ currentAppId: localBinding.currentAppId,
1597
+ upstreamAppId: localBinding.upstreamAppId
1571
1598
  });
1572
1599
  const lane2 = unwrapResponseObject(laneResp2, "project lane binding");
1573
1600
  if (lane2.status === "ambiguous_family_selection") {
@@ -1647,7 +1674,9 @@ async function resolveActiveLaneBindingUncached(params, state) {
1647
1674
  repoFingerprint: state.repoFingerprint ?? void 0,
1648
1675
  remoteUrl: state.remoteUrl ?? void 0,
1649
1676
  defaultBranch: state.defaultBranch ?? void 0,
1650
- branchName: currentBranch
1677
+ branchName: currentBranch,
1678
+ currentAppId: state.binding?.currentAppId,
1679
+ upstreamAppId: state.binding?.upstreamAppId
1651
1680
  });
1652
1681
  const lane = unwrapResponseObject(laneResp, "project lane binding");
1653
1682
  if (lane.status === "ambiguous_family_selection") {
@@ -1768,6 +1797,21 @@ function buildBaseState() {
1768
1797
  }
1769
1798
  };
1770
1799
  }
1800
+ async function refreshEquivalentRevisionBaseline(params) {
1801
+ return writeLocalBaseline({
1802
+ repoRoot: params.repoRoot,
1803
+ repoFingerprint: params.binding.repoFingerprint,
1804
+ laneId: params.binding.laneId,
1805
+ currentAppId: params.binding.currentAppId,
1806
+ branchName: params.binding.branchName,
1807
+ lastSnapshotId: params.baseline.lastSnapshotId,
1808
+ lastSnapshotHash: params.baseline.lastSnapshotHash,
1809
+ lastServerRevisionId: params.appHead.headRevisionId ?? null,
1810
+ lastServerTreeHash: params.appHead.treeHash ?? params.baseline.lastServerTreeHash ?? null,
1811
+ lastServerHeadHash: params.appHead.headCommitHash,
1812
+ lastSeenLocalCommitHash: params.baseline.lastSeenLocalCommitHash
1813
+ });
1814
+ }
1771
1815
  async function collabDetectRepoState(params) {
1772
1816
  const detected = buildBaseState();
1773
1817
  let repoRoot;
@@ -1963,22 +2007,15 @@ async function collabDetectRepoState(params) {
1963
2007
  );
1964
2008
  const equivalentRevisionDrift = revisionChanged && !serverHeadChanged;
1965
2009
  if (equivalentRevisionDrift) {
1966
- await writeLocalBaseline({
2010
+ const refreshedBaseline = await refreshEquivalentRevisionBaseline({
1967
2011
  repoRoot,
1968
- repoFingerprint: binding.repoFingerprint,
1969
- laneId: binding.laneId,
1970
- currentAppId: binding.currentAppId,
1971
- branchName: binding.branchName,
1972
- lastSnapshotId: baseline.lastSnapshotId,
1973
- lastSnapshotHash: baseline.lastSnapshotHash,
1974
- lastServerRevisionId: appHead.headRevisionId ?? null,
1975
- lastServerTreeHash: appHead.treeHash ?? baseline.lastServerTreeHash ?? null,
1976
- lastServerHeadHash: appHead.headCommitHash,
1977
- lastSeenLocalCommitHash: baseline.lastSeenLocalCommitHash
2012
+ binding,
2013
+ baseline,
2014
+ appHead
1978
2015
  });
1979
- detected.baseline.lastServerRevisionId = appHead.headRevisionId ?? null;
1980
- detected.baseline.lastServerTreeHash = appHead.treeHash ?? baseline.lastServerTreeHash ?? null;
1981
- detected.baseline.lastServerHeadHash = appHead.headCommitHash;
2016
+ detected.baseline.lastServerRevisionId = refreshedBaseline.lastServerRevisionId;
2017
+ detected.baseline.lastServerTreeHash = refreshedBaseline.lastServerTreeHash;
2018
+ detected.baseline.lastServerHeadHash = refreshedBaseline.lastServerHeadHash;
1982
2019
  }
1983
2020
  const serverChanged = serverHeadChanged;
1984
2021
  if (!localChanged && !serverChanged) {
@@ -2005,13 +2042,38 @@ async function collabDetectRepoState(params) {
2005
2042
  }
2006
2043
 
2007
2044
  // src/infrastructure/git/gitEventHarvester.ts
2008
- import { execa as execa2 } from "execa";
2009
2045
  var GIT_TIMEOUT_MS = 1e4;
2010
2046
  var GIT_MAX_BUFFER = 64 * 1024 * 1024;
2011
2047
  var MAX_COMMITS = 100;
2012
2048
  var RECORD_SEPARATOR = "";
2013
2049
  var FIELD_SEPARATOR = "";
2014
2050
  var LOG_FORMAT = `${RECORD_SEPARATOR}%H${FIELD_SEPARATOR}%P${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%cn${FIELD_SEPARATOR}%ce${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%cI${FIELD_SEPARATOR}%s`;
2051
+ function buildGitEventStatusArgs() {
2052
+ return ["status", "--porcelain=v1"];
2053
+ }
2054
+ function buildGitEventDiffNumstatArgs(params) {
2055
+ if (params.kind === "uncommitted") {
2056
+ return ["diff", "--numstat", "HEAD"];
2057
+ }
2058
+ return ["diff", "--numstat", "--no-renames", params.fromCommit, params.toCommit];
2059
+ }
2060
+ function buildGitEventReachabilityArgs(fromCommit, toCommit) {
2061
+ return ["merge-base", "--is-ancestor", fromCommit, toCommit];
2062
+ }
2063
+ function buildGitEventLogArgs(fromCommit, toCommit) {
2064
+ return [
2065
+ "log",
2066
+ "--first-parent",
2067
+ "-m",
2068
+ "--numstat",
2069
+ "--no-renames",
2070
+ `--format=${LOG_FORMAT}`,
2071
+ `${fromCommit}..${toCommit}`
2072
+ ];
2073
+ }
2074
+ function buildGitEventNameRevArgs(secondParent) {
2075
+ return ["name-rev", "--name-only", "--no-undefined", secondParent];
2076
+ }
2015
2077
  function sanitizedEnv() {
2016
2078
  const env = { ...process.env };
2017
2079
  delete env.GIT_DIR;
@@ -2020,21 +2082,16 @@ function sanitizedEnv() {
2020
2082
  delete env.GIT_INDEX_FILE;
2021
2083
  return env;
2022
2084
  }
2023
- async function runGitEvent(args, cwd) {
2024
- const res = await execa2("git", args, {
2025
- cwd,
2026
- reject: false,
2027
- stderr: "pipe",
2028
- timeout: GIT_TIMEOUT_MS,
2029
- env: sanitizedEnv(),
2030
- maxBuffer: GIT_MAX_BUFFER
2031
- });
2032
- return {
2033
- exitCode: res.exitCode ?? -1,
2034
- stdout: String(res.stdout ?? ""),
2035
- stderr: String(res.stderr ?? ""),
2036
- timedOut: res.timedOut === true
2037
- };
2085
+ async function runGitEvent(args, cwd, adapter = createSystemGitAdapter()) {
2086
+ return adapter.run(
2087
+ createGitCommandPlan({
2088
+ args,
2089
+ cwd,
2090
+ env: sanitizedEnv(),
2091
+ timeoutMs: GIT_TIMEOUT_MS,
2092
+ maxBuffer: GIT_MAX_BUFFER
2093
+ })
2094
+ );
2038
2095
  }
2039
2096
  function parseNumstatLine(line) {
2040
2097
  const tab1 = line.indexOf(" ");
@@ -2123,10 +2180,11 @@ function parseBranchFromMergeMessage(subject) {
2123
2180
  }
2124
2181
  return null;
2125
2182
  }
2126
- async function resolveBranchFromNameRev(secondParent, repoRoot) {
2183
+ async function resolveBranchFromNameRev(secondParent, repoRoot, adapter) {
2127
2184
  const res = await runGitEvent(
2128
- ["name-rev", "--name-only", "--no-undefined", secondParent],
2129
- repoRoot
2185
+ buildGitEventNameRevArgs(secondParent),
2186
+ repoRoot,
2187
+ adapter
2130
2188
  );
2131
2189
  if (res.exitCode !== 0 || res.timedOut) return null;
2132
2190
  let name = res.stdout.trim();
@@ -2136,7 +2194,35 @@ async function resolveBranchFromNameRev(secondParent, repoRoot) {
2136
2194
  if (name.includes("~") || name.includes("^") || name.startsWith("tags/") || name === "undefined") return null;
2137
2195
  return name;
2138
2196
  }
2139
- async function collectUncommittedSummary(repoRoot) {
2197
+ function summarizePorcelainStatus(stdout) {
2198
+ const summary = {
2199
+ filesChanged: 0,
2200
+ staged: 0,
2201
+ unstaged: 0,
2202
+ untracked: 0
2203
+ };
2204
+ const uniquePaths = /* @__PURE__ */ new Set();
2205
+ for (const rawLine of stdout.split("\n")) {
2206
+ if (!rawLine) continue;
2207
+ if (rawLine.startsWith("??")) {
2208
+ summary.untracked += 1;
2209
+ uniquePaths.add(rawLine.slice(3));
2210
+ continue;
2211
+ }
2212
+ if (rawLine.length < 3) continue;
2213
+ const indexCode = rawLine.charCodeAt(0);
2214
+ const worktreeCode = rawLine.charCodeAt(1);
2215
+ const indexCh = rawLine[0];
2216
+ const worktreeCh = rawLine[1];
2217
+ const pathPart = rawLine.slice(3);
2218
+ if (indexCh !== " " && indexCode !== 63) summary.staged += 1;
2219
+ if ((worktreeCh === "M" || worktreeCh === "D") && worktreeCode !== 63) summary.unstaged += 1;
2220
+ uniquePaths.add(pathPart);
2221
+ }
2222
+ summary.filesChanged = uniquePaths.size;
2223
+ return summary;
2224
+ }
2225
+ async function collectUncommittedSummary(repoRoot, adapter) {
2140
2226
  const summary = {
2141
2227
  filesChanged: 0,
2142
2228
  linesAdded: 0,
@@ -2145,29 +2231,11 @@ async function collectUncommittedSummary(repoRoot) {
2145
2231
  unstaged: 0,
2146
2232
  untracked: 0
2147
2233
  };
2148
- const statusRes = await runGitEvent(["status", "--porcelain=v1"], repoRoot);
2234
+ const statusRes = await runGitEvent(buildGitEventStatusArgs(), repoRoot, adapter);
2149
2235
  if (statusRes.exitCode === 0 && !statusRes.timedOut) {
2150
- const uniquePaths = /* @__PURE__ */ new Set();
2151
- for (const rawLine of statusRes.stdout.split("\n")) {
2152
- if (!rawLine) continue;
2153
- if (rawLine.startsWith("??")) {
2154
- summary.untracked += 1;
2155
- uniquePaths.add(rawLine.slice(3));
2156
- continue;
2157
- }
2158
- if (rawLine.length < 3) continue;
2159
- const indexCode = rawLine.charCodeAt(0);
2160
- const worktreeCode = rawLine.charCodeAt(1);
2161
- const indexCh = rawLine[0];
2162
- const worktreeCh = rawLine[1];
2163
- const pathPart = rawLine.slice(3);
2164
- if (indexCh !== " " && indexCode !== 63) summary.staged += 1;
2165
- if ((worktreeCh === "M" || worktreeCh === "D") && worktreeCode !== 63) summary.unstaged += 1;
2166
- uniquePaths.add(pathPart);
2167
- }
2168
- summary.filesChanged = uniquePaths.size;
2236
+ Object.assign(summary, summarizePorcelainStatus(statusRes.stdout));
2169
2237
  }
2170
- const diffRes = await runGitEvent(["diff", "--numstat", "HEAD"], repoRoot);
2238
+ const diffRes = await runGitEvent(buildGitEventDiffNumstatArgs({ kind: "uncommitted" }), repoRoot, adapter);
2171
2239
  if (diffRes.exitCode === 0 && !diffRes.timedOut) {
2172
2240
  for (const rawLine of diffRes.stdout.split("\n")) {
2173
2241
  if (!rawLine) continue;
@@ -2200,7 +2268,8 @@ function buildEmptyPreTurnEvents(params) {
2200
2268
  }
2201
2269
  async function collectPreTurnGitEvents(params) {
2202
2270
  try {
2203
- const uncommitted = await collectUncommittedSummary(params.repoRoot);
2271
+ const git = params.git ?? createSystemGitAdapter();
2272
+ const uncommitted = await collectUncommittedSummary(params.repoRoot, git);
2204
2273
  if (!params.fromCommit || params.fromCommit === params.toCommit) {
2205
2274
  return buildEmptyPreTurnEvents({
2206
2275
  fromCommit: params.fromCommit,
@@ -2210,8 +2279,9 @@ async function collectPreTurnGitEvents(params) {
2210
2279
  });
2211
2280
  }
2212
2281
  const reachabilityRes = await runGitEvent(
2213
- ["merge-base", "--is-ancestor", params.fromCommit, params.toCommit],
2214
- params.repoRoot
2282
+ buildGitEventReachabilityArgs(params.fromCommit, params.toCommit),
2283
+ params.repoRoot,
2284
+ git
2215
2285
  );
2216
2286
  if (reachabilityRes.timedOut) return null;
2217
2287
  if (reachabilityRes.exitCode === 1) {
@@ -2224,16 +2294,9 @@ async function collectPreTurnGitEvents(params) {
2224
2294
  }
2225
2295
  if (reachabilityRes.exitCode !== 0) return null;
2226
2296
  const logRes = await runGitEvent(
2227
- [
2228
- "log",
2229
- "--first-parent",
2230
- "-m",
2231
- "--numstat",
2232
- "--no-renames",
2233
- `--format=${LOG_FORMAT}`,
2234
- `${params.fromCommit}..${params.toCommit}`
2235
- ],
2236
- params.repoRoot
2297
+ buildGitEventLogArgs(params.fromCommit, params.toCommit),
2298
+ params.repoRoot,
2299
+ git
2237
2300
  );
2238
2301
  if (logRes.timedOut || logRes.exitCode !== 0) return null;
2239
2302
  const rawCommits = parseLogOutput(logRes.stdout);
@@ -2248,7 +2311,7 @@ async function collectPreTurnGitEvents(params) {
2248
2311
  if (raw.parents.length < 2) continue;
2249
2312
  const secondParent = raw.parents[1];
2250
2313
  const fromBranchFromMessage = parseBranchFromMergeMessage(raw.subject);
2251
- const fromBranchFromNameRev = await resolveBranchFromNameRev(secondParent, params.repoRoot);
2314
+ const fromBranchFromNameRev = await resolveBranchFromNameRev(secondParent, params.repoRoot, git);
2252
2315
  merges.push({
2253
2316
  hash: raw.hash,
2254
2317
  shortHash: raw.hash.slice(0, 7),
@@ -2265,8 +2328,9 @@ async function collectPreTurnGitEvents(params) {
2265
2328
  let totalLinesAdded = 0;
2266
2329
  let totalLinesRemoved = 0;
2267
2330
  const diffRes = await runGitEvent(
2268
- ["diff", "--numstat", "--no-renames", params.fromCommit, params.toCommit],
2269
- params.repoRoot
2331
+ buildGitEventDiffNumstatArgs({ kind: "range", fromCommit: params.fromCommit, toCommit: params.toCommit }),
2332
+ params.repoRoot,
2333
+ git
2270
2334
  );
2271
2335
  if (diffRes.exitCode === 0 && !diffRes.timedOut) {
2272
2336
  const uniquePaths = /* @__PURE__ */ new Set();
@@ -2317,13 +2381,184 @@ async function collectPreTurnGitEvents(params) {
2317
2381
  }
2318
2382
  }
2319
2383
 
2384
+ // src/application/collab/jobRunner.ts
2385
+ async function drainClaimedJobs(params) {
2386
+ await params.prune();
2387
+ const jobs = await params.list();
2388
+ const results = [];
2389
+ for (const listedJob of jobs) {
2390
+ if (params.isTerminal(listedJob)) continue;
2391
+ const claimed = await params.claim(listedJob);
2392
+ if (!claimed) continue;
2393
+ try {
2394
+ const result = await params.process(claimed);
2395
+ results.push(result);
2396
+ await params.afterSuccess?.({ listedJob, job: claimed.job, result });
2397
+ } catch (error) {
2398
+ await claimed.release();
2399
+ if (params.onError === "throw") throw error;
2400
+ }
2401
+ }
2402
+ return results;
2403
+ }
2404
+
2405
+ // src/application/collab/workflowState.ts
2406
+ function asyncJobWorkflowState(status) {
2407
+ if (status === "completed" || status === "failed" || status === "queued") return status;
2408
+ return "processing";
2409
+ }
2410
+ function planAsyncJobDrainTransition(job) {
2411
+ const state = asyncJobWorkflowState(job.status);
2412
+ if (state === "completed" || state === "failed") {
2413
+ return { workflow: "async_job_drain", state, action: "skip_terminal", terminal: true };
2414
+ }
2415
+ return {
2416
+ workflow: "async_job_drain",
2417
+ state,
2418
+ action: job.kind === "init" ? "process_init" : "process_init_post",
2419
+ terminal: false
2420
+ };
2421
+ }
2422
+ function planFinalizeQueueTransition(job) {
2423
+ if (job.status === "completed" || job.status === "failed" && job.metadata.failureDisposition === "terminal") {
2424
+ return { workflow: "finalize", state: job.status, action: "skip_terminal", terminal: true };
2425
+ }
2426
+ if (job.status === "failed") {
2427
+ return { workflow: "finalize", state: "retryable_failed", action: "claim_and_process", terminal: false };
2428
+ }
2429
+ return { workflow: "finalize", state: job.status, action: "claim_and_process", terminal: false };
2430
+ }
2431
+ function planCheckoutWorkflowTransition(params) {
2432
+ if (!params.appId?.trim()) {
2433
+ return { workflow: "checkout", state: "failed", action: "require_app", terminal: true };
2434
+ }
2435
+ return { workflow: "checkout", state: "selected", action: "checkout", terminal: false };
2436
+ }
2437
+ function planApproveWorkflowTransition(params) {
2438
+ if (params.mode === "sync-target-repo") {
2439
+ return params.hasCwd ? { workflow: "approve", state: "sync_target_repo", action: "sync_target_repo", terminal: false } : { workflow: "approve", state: "failed", action: "require_cwd", terminal: true };
2440
+ }
2441
+ return { workflow: "approve", state: "remote_only", action: "approve_remote", terminal: false };
2442
+ }
2443
+ function planRequestMergeWorkflowTransition(params) {
2444
+ if (!params.hasBinding) {
2445
+ return { workflow: "request_merge", state: "failed", action: "require_binding", terminal: true };
2446
+ }
2447
+ if (params.hasPendingFinalize) {
2448
+ return { workflow: "request_merge", state: "pending_finalize", action: "require_finalize_drain", terminal: true };
2449
+ }
2450
+ return { workflow: "request_merge", state: "bound", action: "open_merge_request", terminal: false };
2451
+ }
2452
+
2453
+ // src/application/collab/finalizeQueueRunner.ts
2454
+ async function drainPendingFinalizeQueueJobs(params) {
2455
+ return drainClaimedJobs({
2456
+ prune: prunePendingFinalizeJobs,
2457
+ list: listPendingFinalizeJobs,
2458
+ isTerminal: (job) => planFinalizeQueueTransition(job).terminal,
2459
+ claim: (job) => claimPendingFinalizeJob(job.id),
2460
+ process: (claimed) => params.processClaimedJob({
2461
+ api: params.api,
2462
+ job: claimed.job,
2463
+ release: claimed.release
2464
+ }),
2465
+ afterSuccess: async ({ listedJob }) => {
2466
+ await removePendingFinalizeJob(listedJob.id);
2467
+ }
2468
+ });
2469
+ }
2470
+
2471
+ // src/application/collab/finalizeWorkspaceMetadata.ts
2472
+ function buildWorkspaceMetadata(params) {
2473
+ const metadata = {
2474
+ branch: params.branchName,
2475
+ repoRoot: params.repoRoot,
2476
+ remoteUrl: params.remoteUrl,
2477
+ defaultBranch: params.defaultBranch,
2478
+ recordingMode: "boundary_delta",
2479
+ baselineSnapshotId: params.baselineSnapshotId,
2480
+ currentSnapshotId: params.currentSnapshotId,
2481
+ baselineServerRevisionId: params.baselineServerRevisionId ?? null,
2482
+ baselineServerHeadHash: params.baselineServerHeadHash,
2483
+ currentSnapshotHash: params.currentSnapshotHash,
2484
+ localCommitHash: params.localCommitHash,
2485
+ repoStateAtCapture: params.repoState,
2486
+ replayedFromBaseHash: params.replayedFromBaseHash ?? null,
2487
+ previousLocalCommitHash: params.previousLocalCommitHash ?? null
2488
+ };
2489
+ if (params.preTurnEvents) {
2490
+ metadata.preTurnEvents = params.preTurnEvents;
2491
+ }
2492
+ if (params.turnUsage) {
2493
+ metadata.turnUsage = params.turnUsage;
2494
+ }
2495
+ if (typeof params.promptedAt === "string" && params.promptedAt.trim()) {
2496
+ metadata.promptedAt = params.promptedAt.trim();
2497
+ }
2498
+ return metadata;
2499
+ }
2500
+
2501
+ // src/application/collab/finalizeDecision.ts
2502
+ var FINALIZE_AUTO_TERMINAL_THRESHOLD = 5;
2503
+ function classifyFinalizeError(error) {
2504
+ const tagged = error;
2505
+ return {
2506
+ disposition: tagged.finalizeDisposition ?? "retryable",
2507
+ reason: tagged.finalizeReason ?? "unknown",
2508
+ message: error instanceof Error ? error.message : String(error)
2509
+ };
2510
+ }
2511
+ function computeFailureEscalation(job, reason) {
2512
+ const previousReason = job.metadata.consecutiveFailureReason;
2513
+ const previousCountRaw = job.metadata.consecutiveFailures;
2514
+ const previousCount = typeof previousCountRaw === "number" && Number.isFinite(previousCountRaw) && previousCountRaw > 0 ? Math.floor(previousCountRaw) : 0;
2515
+ const sameReason = previousReason === reason;
2516
+ const next = sameReason ? previousCount + 1 : 1;
2517
+ return {
2518
+ consecutiveFailures: next,
2519
+ consecutiveFailureReason: reason,
2520
+ shouldEscalateToTerminal: next >= FINALIZE_AUTO_TERMINAL_THRESHOLD
2521
+ };
2522
+ }
2523
+ function hasFinalizeBaselineDrifted(params) {
2524
+ return params.liveSnapshotId !== params.jobSnapshotId || (params.jobServerRevisionId ? params.liveServerRevisionId !== params.jobServerRevisionId : false) || params.liveServerHeadHash !== params.jobServerHeadHash;
2525
+ }
2526
+ function canSalvageNoDiffFinalize(params) {
2527
+ if (params.baselineDrifted && params.currentSnapshotHash !== params.liveBaselineSnapshotHash) return false;
2528
+ return params.serverStillAtBaseline;
2529
+ }
2530
+ function classifyChangedFinalizeDrift(params) {
2531
+ if (!params.baselineDrifted) return "no_drift";
2532
+ return params.localBaselineAdvanced && params.serverHeadAdvanced ? "consistent_advance" : "inconsistent_drift";
2533
+ }
2534
+ function shouldReplayChangedFinalize(params) {
2535
+ return params.currentServerHeadHash !== params.submissionBaseHeadHash || params.baselineDrifted;
2536
+ }
2537
+ function shouldAdoptExistingChangeStepBeforeReplay(params) {
2538
+ return params.replayNeeded && Boolean(params.idempotencyKey?.trim());
2539
+ }
2540
+ function validateSucceededChangeStepForBaseline(params) {
2541
+ const nextServerHeadHash = typeof params.headCommitHash === "string" ? params.headCommitHash.trim() : "";
2542
+ if (!nextServerHeadHash) {
2543
+ throw new RemixError("Backend returned a succeeded change step without a head commit hash.", {
2544
+ exitCode: 1,
2545
+ hint: "This is a backend invariant violation; retry will not help. Run `remix collab status` before trying again."
2546
+ });
2547
+ }
2548
+ const nextServerRevisionId = typeof params.resultRevisionId === "string" ? params.resultRevisionId.trim() : "";
2549
+ return {
2550
+ nextServerHeadHash,
2551
+ nextServerRevisionId: nextServerRevisionId || null,
2552
+ needsFreshHeadVerification: !nextServerRevisionId
2553
+ };
2554
+ }
2555
+
2320
2556
  // src/application/collab/collabFinalizeProcessing.ts
2321
2557
  var FINALIZE_RETRY_BASE_DELAY_MS = 15e3;
2322
2558
  var FINALIZE_RETRY_MAX_DELAY_MS = 5 * 60 * 1e3;
2323
2559
  var FINALIZE_JOB_PROCESSING_TIMEOUT_MS_DEFAULT = 90 * 1e3;
2324
2560
  var finalizeJobProcessingTimeoutMs = FINALIZE_JOB_PROCESSING_TIMEOUT_MS_DEFAULT;
2325
2561
  var FINALIZE_LOCK_HEARTBEAT_MS = Math.max(15e3, Math.floor(FINALIZE_JOB_LOCK_STALE_MS / 3));
2326
- var FINALIZE_AUTO_TERMINAL_THRESHOLD = 5;
2327
2562
  function readMetadataString(job, key) {
2328
2563
  const value = job.metadata[key];
2329
2564
  return typeof value === "string" && value.trim() ? value.trim() : null;
@@ -2350,14 +2585,6 @@ function buildFinalizeCliError(params) {
2350
2585
  error.finalizeReason = params.reason;
2351
2586
  return error;
2352
2587
  }
2353
- function classifyFinalizeError(error) {
2354
- const tagged = error;
2355
- return {
2356
- disposition: tagged.finalizeDisposition ?? "retryable",
2357
- reason: tagged.finalizeReason ?? "unknown",
2358
- message: error instanceof Error ? error.message : String(error)
2359
- };
2360
- }
2361
2588
  function runWithFinalizeTimeout(work, jobId) {
2362
2589
  let timer;
2363
2590
  const cap = finalizeJobProcessingTimeoutMs;
@@ -2388,46 +2615,6 @@ function startLockHeartbeat(jobId) {
2388
2615
  handle.unref?.();
2389
2616
  return () => clearInterval(handle);
2390
2617
  }
2391
- function computeFailureEscalation(job, reason) {
2392
- const previousReason = job.metadata.consecutiveFailureReason;
2393
- const previousCountRaw = job.metadata.consecutiveFailures;
2394
- const previousCount = typeof previousCountRaw === "number" && Number.isFinite(previousCountRaw) && previousCountRaw > 0 ? Math.floor(previousCountRaw) : 0;
2395
- const sameReason = previousReason === reason;
2396
- const next = sameReason ? previousCount + 1 : 1;
2397
- return {
2398
- consecutiveFailures: next,
2399
- consecutiveFailureReason: reason,
2400
- shouldEscalateToTerminal: next >= FINALIZE_AUTO_TERMINAL_THRESHOLD
2401
- };
2402
- }
2403
- function buildWorkspaceMetadata(params) {
2404
- const metadata = {
2405
- branch: params.branchName,
2406
- repoRoot: params.repoRoot,
2407
- remoteUrl: params.remoteUrl,
2408
- defaultBranch: params.defaultBranch,
2409
- recordingMode: "boundary_delta",
2410
- baselineSnapshotId: params.baselineSnapshotId,
2411
- currentSnapshotId: params.currentSnapshotId,
2412
- baselineServerRevisionId: params.baselineServerRevisionId ?? null,
2413
- baselineServerHeadHash: params.baselineServerHeadHash,
2414
- currentSnapshotHash: params.currentSnapshotHash,
2415
- localCommitHash: params.localCommitHash,
2416
- repoStateAtCapture: params.repoState,
2417
- replayedFromBaseHash: params.replayedFromBaseHash ?? null,
2418
- previousLocalCommitHash: params.previousLocalCommitHash ?? null
2419
- };
2420
- if (params.preTurnEvents) {
2421
- metadata.preTurnEvents = params.preTurnEvents;
2422
- }
2423
- if (params.turnUsage) {
2424
- metadata.turnUsage = params.turnUsage;
2425
- }
2426
- if (typeof params.promptedAt === "string" && params.promptedAt.trim()) {
2427
- metadata.promptedAt = params.promptedAt.trim();
2428
- }
2429
- return metadata;
2430
- }
2431
2618
  async function findExistingChangeStepByIdempotency(params) {
2432
2619
  const idempotencyKey = params.idempotencyKey?.trim();
2433
2620
  if (!idempotencyKey) return null;
@@ -2440,19 +2627,28 @@ async function findExistingChangeStepByIdempotency(params) {
2440
2627
  return steps.find((step) => step.idempotencyKey === idempotencyKey) ?? null;
2441
2628
  }
2442
2629
  async function writeBaselineFromSucceededChangeStep(params) {
2443
- const nextServerHeadHash = typeof params.changeStep.headCommitHash === "string" ? params.changeStep.headCommitHash.trim() : "";
2444
- if (!nextServerHeadHash) {
2445
- throw buildFinalizeCliError({
2446
- message: "Backend returned a succeeded change step without a head commit hash.",
2447
- exitCode: 1,
2448
- hint: "This is a backend invariant violation; retry will not help. Run `remix collab status` before trying again.",
2449
- disposition: "terminal",
2450
- reason: "missing_head_commit_hash"
2630
+ let baselineAdvance;
2631
+ try {
2632
+ baselineAdvance = validateSucceededChangeStepForBaseline({
2633
+ headCommitHash: params.changeStep.headCommitHash,
2634
+ resultRevisionId: params.changeStep.resultRevisionId
2451
2635
  });
2636
+ } catch (error) {
2637
+ if (error instanceof RemixError) {
2638
+ throw buildFinalizeCliError({
2639
+ message: error.message,
2640
+ exitCode: error.exitCode,
2641
+ hint: error.hint ?? void 0,
2642
+ disposition: "terminal",
2643
+ reason: "missing_head_commit_hash"
2644
+ });
2645
+ }
2646
+ throw error;
2452
2647
  }
2453
- let nextServerRevisionId = typeof params.changeStep.resultRevisionId === "string" ? params.changeStep.resultRevisionId.trim() : "";
2648
+ const nextServerHeadHash = baselineAdvance.nextServerHeadHash;
2649
+ let nextServerRevisionId = baselineAdvance.nextServerRevisionId ?? "";
2454
2650
  let nextServerTreeHash = null;
2455
- if (!nextServerRevisionId) {
2651
+ if (baselineAdvance.needsFreshHeadVerification) {
2456
2652
  const freshHeadResp = await params.api.getAppHead(params.job.currentAppId);
2457
2653
  const freshHead = unwrapResponseObject(freshHeadResp, "app head");
2458
2654
  if (freshHead.headCommitHash !== nextServerHeadHash || !freshHead.headRevisionId) {
@@ -2546,7 +2742,14 @@ async function processClaimedPendingFinalizeJobInner(params) {
2546
2742
  reason: "baseline_missing"
2547
2743
  });
2548
2744
  }
2549
- const baselineDrifted = baseline.lastSnapshotId !== job.baselineSnapshotId || (job.baselineServerRevisionId ? baseline.lastServerRevisionId !== job.baselineServerRevisionId : false) || baseline.lastServerHeadHash !== job.baselineServerHeadHash;
2745
+ const baselineDrifted = hasFinalizeBaselineDrifted({
2746
+ liveSnapshotId: baseline.lastSnapshotId,
2747
+ jobSnapshotId: job.baselineSnapshotId,
2748
+ liveServerRevisionId: baseline.lastServerRevisionId,
2749
+ jobServerRevisionId: job.baselineServerRevisionId,
2750
+ liveServerHeadHash: baseline.lastServerHeadHash,
2751
+ jobServerHeadHash: job.baselineServerHeadHash
2752
+ });
2550
2753
  const appHead = unwrapResponseObject(appHeadResp, "app head");
2551
2754
  const remoteUrl = readMetadataString(job, "remoteUrl");
2552
2755
  const defaultBranch = readMetadataString(job, "defaultBranch");
@@ -2565,7 +2768,13 @@ async function processClaimedPendingFinalizeJobInner(params) {
2565
2768
  targetSnapshotId: job.currentSnapshotId
2566
2769
  });
2567
2770
  if (!diffResult.diff.trim()) {
2568
- if (baselineDrifted && snapshot.snapshotHash !== baseline.lastSnapshotHash) {
2771
+ const serverStillAtBaseline = job.baselineServerRevisionId ? appHead.headRevisionId === job.baselineServerRevisionId : appHead.headCommitHash === job.baselineServerHeadHash;
2772
+ if (baselineDrifted && !canSalvageNoDiffFinalize({
2773
+ baselineDrifted,
2774
+ currentSnapshotHash: snapshot.snapshotHash,
2775
+ liveBaselineSnapshotHash: baseline.lastSnapshotHash,
2776
+ serverStillAtBaseline: true
2777
+ })) {
2569
2778
  throw buildFinalizeCliError({
2570
2779
  message: "Finalize queue baseline drifted before this job was processed.",
2571
2780
  exitCode: 1,
@@ -2574,8 +2783,7 @@ async function processClaimedPendingFinalizeJobInner(params) {
2574
2783
  reason: "baseline_drifted"
2575
2784
  });
2576
2785
  }
2577
- const serverStillAtBaseline = job.baselineServerRevisionId ? appHead.headRevisionId === job.baselineServerRevisionId : appHead.headCommitHash === job.baselineServerHeadHash;
2578
- if (!serverStillAtBaseline) {
2786
+ if (!canSalvageNoDiffFinalize({ baselineDrifted: false, currentSnapshotHash: "", liveBaselineSnapshotHash: "", serverStillAtBaseline })) {
2579
2787
  throw buildFinalizeCliError({
2580
2788
  message: "Server lane changed before a no-diff turn could be recorded.",
2581
2789
  exitCode: 2,
@@ -2641,9 +2849,9 @@ async function processClaimedPendingFinalizeJobInner(params) {
2641
2849
  }
2642
2850
  const localBaselineAdvanced = baseline.lastSnapshotId !== job.baselineSnapshotId;
2643
2851
  const serverHeadAdvanced = job.baselineServerRevisionId ? appHead.headRevisionId !== job.baselineServerRevisionId : appHead.headCommitHash !== job.baselineServerHeadHash;
2644
- if (baselineDrifted) {
2645
- const consistentAdvance = localBaselineAdvanced && serverHeadAdvanced;
2646
- if (!consistentAdvance) {
2852
+ const changedDrift = classifyChangedFinalizeDrift({ baselineDrifted, localBaselineAdvanced, serverHeadAdvanced });
2853
+ if (changedDrift !== "no_drift") {
2854
+ if (changedDrift === "inconsistent_drift") {
2647
2855
  throw buildFinalizeCliError({
2648
2856
  message: `Finalize queue baseline advanced inconsistently before this job was processed (localBaselineAdvanced=${localBaselineAdvanced}, serverHeadAdvanced=${serverHeadAdvanced}, jobBaselineSnapshotId=${job.baselineSnapshotId ?? "null"}, liveBaselineSnapshotId=${baseline.lastSnapshotId ?? "null"}, jobBaselineServerHeadHash=${job.baselineServerHeadHash ?? "null"}, liveBaselineServerHeadHash=${baseline.lastServerHeadHash ?? "null"}, currentAppHeadHash=${appHead.headCommitHash}). This indicates local Remix state diverged from the backend in a way that should not be reachable in normal operation; please report this as a bug.`,
2649
2857
  exitCode: 1,
@@ -2665,8 +2873,12 @@ async function processClaimedPendingFinalizeJobInner(params) {
2665
2873
  reason: "baseline_server_head_missing"
2666
2874
  });
2667
2875
  }
2668
- const replayNeeded = appHead.headCommitHash !== submissionBaseHeadHash || baselineDrifted;
2669
- if (replayNeeded) {
2876
+ const replayNeeded = shouldReplayChangedFinalize({
2877
+ currentServerHeadHash: appHead.headCommitHash,
2878
+ submissionBaseHeadHash,
2879
+ baselineDrifted
2880
+ });
2881
+ if (shouldAdoptExistingChangeStepBeforeReplay({ replayNeeded, idempotencyKey: job.idempotencyKey })) {
2670
2882
  const existingChangeStep = await findExistingChangeStepByIdempotency({
2671
2883
  api: params.api,
2672
2884
  appId: job.currentAppId,
@@ -2853,24 +3065,10 @@ async function enqueueCapturedFinalizeTurn(params) {
2853
3065
  });
2854
3066
  }
2855
3067
  async function drainPendingFinalizeQueue(params) {
2856
- await prunePendingFinalizeJobs();
2857
- const jobs = await listPendingFinalizeJobs();
2858
- const results = [];
2859
- for (const job of jobs) {
2860
- const claimed = await claimPendingFinalizeJob(job.id);
2861
- if (!claimed) continue;
2862
- try {
2863
- const result = await processClaimedPendingFinalizeJob({
2864
- api: params.api,
2865
- job: claimed.job,
2866
- release: claimed.release
2867
- });
2868
- results.push(result);
2869
- await removePendingFinalizeJob(job.id);
2870
- } catch {
2871
- }
2872
- }
2873
- return results;
3068
+ return drainPendingFinalizeQueueJobs({
3069
+ api: params.api,
3070
+ processClaimedJob: processClaimedPendingFinalizeJob
3071
+ });
2874
3072
  }
2875
3073
 
2876
3074
  // src/application/collab/collabFinalizeTurn.ts
@@ -3123,12 +3321,7 @@ function isFinalizePreflightFailureCode(value) {
3123
3321
  }
3124
3322
 
3125
3323
  // src/application/collab/recordingPreflight.ts
3126
- async function collabRecordingPreflight(params) {
3127
- const detected = await collabDetectRepoState({
3128
- api: params.api,
3129
- cwd: params.cwd,
3130
- allowBranchMismatch: params.allowBranchMismatch
3131
- });
3324
+ function mapDetectedRepoStateToRecordingPreflight(detected) {
3132
3325
  if (detected.status === "not_git_repo") {
3133
3326
  return {
3134
3327
  status: "not_git_repo",
@@ -3177,6 +3370,14 @@ async function collabRecordingPreflight(params) {
3177
3370
  if (detected.repoState === "external_local_base_changed") return { status: "baseline_missing", ...base };
3178
3371
  return { status: "ready", ...base };
3179
3372
  }
3373
+ async function collabRecordingPreflight(params) {
3374
+ const detected = await collabDetectRepoState({
3375
+ api: params.api,
3376
+ cwd: params.cwd,
3377
+ allowBranchMismatch: params.allowBranchMismatch
3378
+ });
3379
+ return mapDetectedRepoStateToRecordingPreflight(detected);
3380
+ }
3180
3381
 
3181
3382
  // src/infrastructure/locking/repoMutationLock.ts
3182
3383
  import fs5 from "fs/promises";
@@ -3638,15 +3839,14 @@ async function collabSync(params) {
3638
3839
 
3639
3840
  // src/application/collab/collabApprove.ts
3640
3841
  async function collabApprove(params) {
3641
- if (params.mode === "sync-target-repo") {
3642
- if (!params.cwd?.trim()) {
3643
- throw new RemixError("Working directory is required for `--sync-target-repo`.", {
3644
- exitCode: 2
3645
- });
3646
- }
3842
+ const transition = planApproveWorkflowTransition({
3843
+ mode: params.mode,
3844
+ hasCwd: Boolean(params.cwd?.trim())
3845
+ });
3846
+ if (transition.action === "sync_target_repo") {
3647
3847
  return withRepoMutationLock(
3648
3848
  {
3649
- cwd: params.cwd,
3849
+ cwd: params.cwd ?? "",
3650
3850
  operation: "collabApproveSyncTarget"
3651
3851
  },
3652
3852
  async ({ repoRoot, warnings }) => {
@@ -3705,6 +3905,11 @@ async function collabApprove(params) {
3705
3905
  }
3706
3906
  );
3707
3907
  }
3908
+ if (transition.action === "require_cwd") {
3909
+ throw new RemixError("Working directory is required for `--sync-target-repo`.", {
3910
+ exitCode: 2
3911
+ });
3912
+ }
3708
3913
  const resp = await params.api.updateMergeRequest(params.mrId, { status: "approved" });
3709
3914
  const approvedMergeRequest = unwrapMergeRequest(resp);
3710
3915
  const completedMergeRequest = await pollMergeRequestCompletion(params.api, params.mrId, {
@@ -3841,13 +4046,14 @@ async function materializeAppCheckout(params) {
3841
4046
 
3842
4047
  // src/application/collab/collabCheckout.ts
3843
4048
  async function collabCheckout(params) {
3844
- const appId = params.appId?.trim() || null;
3845
- if (!appId) {
4049
+ const transition = planCheckoutWorkflowTransition({ appId: params.appId });
4050
+ if (transition.action === "require_app") {
3846
4051
  throw new RemixError("No app selected.", {
3847
4052
  exitCode: 2,
3848
4053
  hint: "Pass the app id to checkout."
3849
4054
  });
3850
4055
  }
4056
+ const appId = params.appId?.trim() ?? "";
3851
4057
  const app = await pollAppReady(params.api, appId);
3852
4058
  const collab = app.collab && typeof app.collab === "object" ? app.collab : null;
3853
4059
  const source = app.source && typeof app.source === "object" ? app.source : null;
@@ -4101,38 +4307,16 @@ async function collabUpdateMemberRole(params) {
4101
4307
  }
4102
4308
 
4103
4309
  // src/application/collab/collabInit.ts
4104
- import { randomUUID as randomUUID4 } from "crypto";
4105
- import fs10 from "fs/promises";
4106
- import path9 from "path";
4107
-
4108
- // src/infrastructure/collab/drainerLog.ts
4109
- import fs7 from "fs/promises";
4110
- import path8 from "path";
4111
- async function logDrainerEvent(jobId, event, fields = {}) {
4112
- const entry = {
4113
- ts: (/* @__PURE__ */ new Date()).toISOString(),
4114
- jobId,
4115
- event,
4116
- ...fields
4117
- };
4118
- try {
4119
- await fs7.mkdir(getLogsRoot(), { recursive: true });
4120
- await fs7.appendFile(getDrainerLogPath(), `${JSON.stringify(entry)}
4121
- `, "utf8");
4122
- } catch {
4123
- }
4124
- }
4125
- function drainerLogPath() {
4126
- return getDrainerLogPath();
4127
- }
4310
+ import fs11 from "fs/promises";
4311
+ import path10 from "path";
4128
4312
 
4129
4313
  // src/shared/hash.ts
4130
4314
  import crypto from "crypto";
4131
- import fs8 from "fs";
4315
+ import fs7 from "fs";
4132
4316
  async function sha256FileHex(filePath) {
4133
4317
  const hash = crypto.createHash("sha256");
4134
4318
  await new Promise((resolve, reject) => {
4135
- const stream = fs8.createReadStream(filePath);
4319
+ const stream = fs7.createReadStream(filePath);
4136
4320
  stream.on("data", (chunk) => hash.update(chunk));
4137
4321
  stream.on("error", reject);
4138
4322
  stream.on("end", () => resolve());
@@ -4140,145 +4324,52 @@ async function sha256FileHex(filePath) {
4140
4324
  return hash.digest("hex");
4141
4325
  }
4142
4326
 
4143
- // src/shared/upload.ts
4144
- import fs9 from "fs";
4145
- import { PassThrough } from "stream";
4146
- async function uploadPresigned(params) {
4147
- const stats = await fs9.promises.stat(params.filePath).catch(() => null);
4148
- if (!stats || !stats.isFile()) {
4149
- throw new RemixError("Upload file not found.", { exitCode: 2 });
4150
- }
4151
- const totalBytes = stats.size;
4152
- const fileStream = fs9.createReadStream(params.filePath);
4153
- const pass = new PassThrough();
4154
- let sentBytes = 0;
4155
- fileStream.on("data", (chunk) => {
4156
- sentBytes += chunk.length;
4157
- params.onProgress?.({ sentBytes, totalBytes });
4158
- });
4159
- fileStream.on("error", (err) => pass.destroy(err));
4160
- fileStream.pipe(pass);
4161
- const response = await fetch(params.uploadUrl, {
4162
- method: "PUT",
4163
- headers: params.headers,
4164
- body: pass,
4165
- duplex: "half"
4166
- });
4167
- if (!response.ok) {
4168
- const text = await response.text().catch(() => "");
4169
- throw new RemixError("Upload failed.", {
4170
- exitCode: 1,
4171
- hint: `Status: ${response.status}
4172
- ${text}`.trim() || null
4173
- });
4174
- }
4175
- }
4176
-
4177
- // src/application/collab/collabInit.ts
4178
- function requireResolvedLaneBinding(lane, params) {
4179
- if (lane.status === "resolved" && lane.currentAppId && lane.upstreamAppId) {
4180
- return lane;
4181
- }
4182
- const branchLabel = params.branchName ?? "the current branch";
4183
- const laneStatus = String(lane.status ?? "");
4184
- throw new RemixError(`Failed to resolve a Remix lane for ${branchLabel}.`, {
4185
- exitCode: 1,
4186
- hint: laneStatus === "binding_not_found" ? `Run ${params.operation} again after the repository has been initialized.` : laneStatus === "ambiguous_family_selection" ? "Multiple canonical Remix families match this repository. Continue from a checkout already bound to the intended family, or rerun with `--force-new` to create a new family." : `Remix did not return complete lane metadata for ${branchLabel}.`
4187
- });
4327
+ // src/application/collab/collabInitDecision.ts
4328
+ function shouldRequireCleanWorktreeForInit(params) {
4329
+ return Boolean(params.forceNew) || !params.hasExistingBinding;
4188
4330
  }
4189
- function resolveProjectBindingResult(response) {
4190
- const payload = response?.responseObject;
4191
- if (!payload || typeof payload !== "object") {
4192
- return { status: "not_found" };
4193
- }
4194
- if (payload.status === "ambiguous_family_selection") {
4195
- const projectIds = Array.isArray(payload.projectIds) ? payload.projectIds.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
4196
- return {
4197
- status: "ambiguous_family_selection",
4198
- candidateCount: typeof payload.candidateCount === "number" && Number.isFinite(payload.candidateCount) ? payload.candidateCount : projectIds.length,
4199
- projectIds
4200
- };
4201
- }
4202
- if (payload.projectId && payload.appId) {
4203
- return {
4204
- status: "resolved",
4205
- projectId: String(payload.projectId),
4206
- appId: String(payload.appId),
4207
- upstreamAppId: String(payload.upstreamAppId ?? payload.appId),
4208
- threadId: payload.threadId ? String(payload.threadId) : null
4209
- };
4210
- }
4211
- return { status: "not_found" };
4331
+ function classifyExistingInitBaseline(baseline) {
4332
+ return baseline?.lastSnapshotHash && (baseline.lastServerRevisionId || baseline.lastServerHeadHash) ? "existing" : null;
4212
4333
  }
4213
- function throwAmbiguousFamilyError(params) {
4214
- const familyCount = params.candidateCount || params.projectIds.length;
4215
- const projectHint = params.projectIds.length > 0 ? ` Matching project ids: ${params.projectIds.join(", ")}.` : "";
4216
- const remoteHint = params.remoteUrl ? ` (${params.remoteUrl})` : "";
4217
- throw new RemixError("Multiple canonical Remix families already match this repository.", {
4218
- exitCode: 2,
4219
- hint: `Plain \`remix collab init\` cannot safely choose among ${familyCount} matching canonical families for repo fingerprint ${params.repoFingerprint}${remoteHint}.${projectHint} Run \`remix collab init --force-new\` to create a new canonical family, or continue from a checkout already bound to the intended family.`
4220
- });
4221
- }
4222
- async function resolveOrEnsureLaneBinding(params) {
4223
- const resolvePayload = {
4224
- projectId: params.projectId ?? void 0,
4225
- repoFingerprint: params.repoFingerprint,
4226
- remoteUrl: params.remoteUrl ?? void 0,
4227
- defaultBranch: params.defaultBranch ?? void 0,
4228
- branchName: params.branchName
4229
- };
4230
- let lane = unwrapResponseObject(
4231
- await params.api.resolveProjectLaneBinding(resolvePayload),
4232
- "project lane binding"
4233
- );
4234
- if (lane.status !== "resolved") {
4235
- lane = unwrapResponseObject(
4236
- await params.api.ensureProjectLaneBinding({
4237
- ...resolvePayload,
4238
- seedAppId: params.seedAppId ?? void 0,
4239
- seedHeadCommitHash: params.seedHeadCommitHash ?? void 0
4240
- }),
4241
- "project lane binding"
4242
- );
4243
- }
4244
- return requireResolvedLaneBinding(lane, {
4245
- branchName: params.branchName,
4246
- operation: params.operation
4247
- });
4334
+ function classifyInitBaselineDelta(params) {
4335
+ if (params.equivalentBaselineStatus) return params.equivalentBaselineStatus;
4336
+ if (params.deltaStatus === "content_equivalent") return "seeded";
4337
+ if (params.deltaStatus === "up_to_date" || params.deltaStatus === "delta_ready") return "requires_sync";
4338
+ return null;
4248
4339
  }
4249
- function branchBindingFromLane(lane, mode, fallback) {
4250
- return {
4251
- projectId: lane.projectId ?? fallback.projectId,
4252
- currentAppId: lane.currentAppId ?? fallback.currentAppId,
4253
- upstreamAppId: lane.upstreamAppId ?? fallback.upstreamAppId,
4254
- threadId: lane.threadId ?? fallback.threadId,
4255
- laneId: lane.laneId ?? null,
4256
- bindingMode: mode
4257
- };
4340
+ function canSeedEquivalentBranchBaseline(params) {
4341
+ if (!params.branchName || !params.defaultBranch || params.branchName === params.defaultBranch) return false;
4342
+ if (!params.defaultBranchCurrentAppId) return false;
4343
+ if (params.upstreamAppId && params.defaultBranchCurrentAppId !== params.upstreamAppId) return false;
4344
+ if (!params.defaultBaselineSnapshotHash || !params.defaultBaselineServerHeadHash) return false;
4345
+ if (params.defaultBaselineServerHeadHash !== params.appHeadHash) return false;
4346
+ return params.localSnapshotHash === params.defaultBaselineSnapshotHash || params.localCommitHash === params.defaultBaselineLocalCommitHash;
4258
4347
  }
4259
4348
  function resolveStoredBindingAnchor(params) {
4260
4349
  const currentBranchBinding = params.currentBranch ? params.state.branchBindings[params.currentBranch] ?? null : null;
4261
4350
  if (currentBranchBinding?.currentAppId) {
4262
- return {
4263
- binding: currentBranchBinding,
4264
- branchName: params.currentBranch
4265
- };
4351
+ return { binding: currentBranchBinding, branchName: params.currentBranch };
4266
4352
  }
4267
4353
  const defaultBranchBinding = params.defaultBranch ? params.state.branchBindings[params.defaultBranch] ?? null : null;
4268
4354
  if (defaultBranchBinding?.currentAppId) {
4269
- return {
4270
- binding: defaultBranchBinding,
4271
- branchName: params.defaultBranch
4272
- };
4355
+ return { binding: defaultBranchBinding, branchName: params.defaultBranch };
4273
4356
  }
4274
4357
  return null;
4275
4358
  }
4359
+ function shouldReplaceAsyncInitJobs(params) {
4360
+ return Boolean(params.forceNew);
4361
+ }
4362
+ function shouldReturnPendingAsyncInit(params) {
4363
+ return !params.forceNew && params.pendingJobFound;
4364
+ }
4365
+
4366
+ // src/application/collab/collabInitBaseline.ts
4276
4367
  async function trySeedEquivalentBranchBaseline(params) {
4277
4368
  if (!params.branchName || !params.defaultBranch || params.branchName === params.defaultBranch) {
4278
4369
  return null;
4279
4370
  }
4280
4371
  const bindingState = await readCollabBindingState(params.repoRoot);
4281
- const defaultBranchBinding = bindingState?.branchBindings[params.defaultBranch] ?? null;
4372
+ const defaultBranchBinding = params.defaultBranch ? bindingState?.branchBindings[params.defaultBranch] ?? null : null;
4282
4373
  if (!defaultBranchBinding?.currentAppId) {
4283
4374
  return null;
4284
4375
  }
@@ -4303,7 +4394,18 @@ async function trySeedEquivalentBranchBaseline(params) {
4303
4394
  branchName: params.branchName,
4304
4395
  persistBlobs: false
4305
4396
  });
4306
- if (inspection.snapshotHash !== defaultBaseline.lastSnapshotHash && inspection.localCommitHash !== defaultBaseline.lastSeenLocalCommitHash) {
4397
+ if (!canSeedEquivalentBranchBaseline({
4398
+ branchName: params.branchName,
4399
+ defaultBranch: params.defaultBranch,
4400
+ upstreamAppId: params.upstreamAppId,
4401
+ defaultBranchCurrentAppId: defaultBranchBinding?.currentAppId ?? null,
4402
+ defaultBaselineSnapshotHash: defaultBaseline?.lastSnapshotHash ?? null,
4403
+ defaultBaselineServerHeadHash: defaultBaseline?.lastServerHeadHash ?? null,
4404
+ defaultBaselineLocalCommitHash: defaultBaseline?.lastSeenLocalCommitHash ?? null,
4405
+ appHeadHash: params.appHeadHash,
4406
+ localSnapshotHash: inspection.snapshotHash,
4407
+ localCommitHash: inspection.localCommitHash
4408
+ })) {
4307
4409
  return null;
4308
4410
  }
4309
4411
  await writeLocalBaseline({
@@ -4327,9 +4429,8 @@ async function resolveInitBaselineStatus(params) {
4327
4429
  laneId: params.laneId,
4328
4430
  repoRoot: params.repoRoot
4329
4431
  });
4330
- if (baseline?.lastSnapshotHash && (baseline.lastServerRevisionId || baseline.lastServerHeadHash)) {
4331
- return "existing";
4332
- }
4432
+ const existingBaselineStatus = classifyExistingInitBaseline(baseline);
4433
+ if (existingBaselineStatus) return existingBaselineStatus;
4333
4434
  const localHeadCommitHash = await getHeadCommitHash(params.repoRoot);
4334
4435
  if (!localHeadCommitHash) return "baseline_missing";
4335
4436
  const appHead = unwrapResponseObject(
@@ -4381,7 +4482,7 @@ async function resolveInitBaselineStatus(params) {
4381
4482
  if (equivalentBaseline) {
4382
4483
  return equivalentBaseline;
4383
4484
  }
4384
- return "requires_sync";
4485
+ return classifyInitBaselineDelta({ deltaStatus: delta.status }) ?? "baseline_missing";
4385
4486
  }
4386
4487
  if (delta.status === "content_equivalent") {
4387
4488
  await seedImportedInitBaseline({
@@ -4392,7 +4493,7 @@ async function resolveInitBaselineStatus(params) {
4392
4493
  currentAppId: params.currentAppId,
4393
4494
  branchName: params.branchName
4394
4495
  });
4395
- return "seeded";
4496
+ return classifyInitBaselineDelta({ deltaStatus: delta.status }) ?? "baseline_missing";
4396
4497
  }
4397
4498
  if (delta.status === "base_unknown") {
4398
4499
  const equivalentBaseline = await trySeedEquivalentBranchBaseline({
@@ -4413,34 +4514,317 @@ async function resolveInitBaselineStatus(params) {
4413
4514
  }
4414
4515
  } catch {
4415
4516
  }
4416
- return "baseline_missing";
4517
+ return "baseline_missing";
4518
+ }
4519
+ async function seedImportedInitBaseline(params) {
4520
+ const appHead = unwrapResponseObject(
4521
+ await params.api.getAppHead(params.currentAppId),
4522
+ "app head"
4523
+ );
4524
+ const snapshot = await captureLocalSnapshot({
4525
+ repoRoot: params.repoRoot,
4526
+ repoFingerprint: params.repoFingerprint,
4527
+ laneId: params.laneId,
4528
+ branchName: params.branchName
4529
+ });
4530
+ await writeLocalBaseline({
4531
+ repoRoot: params.repoRoot,
4532
+ repoFingerprint: params.repoFingerprint,
4533
+ laneId: params.laneId,
4534
+ currentAppId: params.currentAppId,
4535
+ branchName: params.branchName,
4536
+ lastSnapshotId: snapshot.id,
4537
+ lastSnapshotHash: snapshot.snapshotHash,
4538
+ lastServerRevisionId: appHead.headRevisionId ?? null,
4539
+ lastServerTreeHash: appHead.treeHash ?? null,
4540
+ lastServerHeadHash: appHead.headCommitHash,
4541
+ lastSeenLocalCommitHash: snapshot.localCommitHash
4542
+ });
4543
+ return "seeded";
4544
+ }
4545
+
4546
+ // src/application/collab/collabInitBinding.ts
4547
+ function requireResolvedLaneBinding(lane, params) {
4548
+ if (lane.status === "resolved" && lane.currentAppId && lane.upstreamAppId) {
4549
+ return lane;
4550
+ }
4551
+ const branchLabel = params.branchName ?? "the current branch";
4552
+ const laneStatus = String(lane.status ?? "");
4553
+ throw new RemixError(`Failed to resolve a Remix lane for ${branchLabel}.`, {
4554
+ exitCode: 1,
4555
+ hint: laneStatus === "binding_not_found" ? `Run ${params.operation} again after the repository has been initialized.` : laneStatus === "ambiguous_family_selection" ? "Multiple canonical Remix families match this repository. Continue from a checkout already bound to the intended family, or rerun with `--force-new` to create a new family." : `Remix did not return complete lane metadata for ${branchLabel}.`
4556
+ });
4557
+ }
4558
+ function resolveProjectBindingResult(response) {
4559
+ const payload = response?.responseObject;
4560
+ if (!payload || typeof payload !== "object") {
4561
+ return { status: "not_found" };
4562
+ }
4563
+ if (payload.status === "ambiguous_family_selection") {
4564
+ const projectIds = Array.isArray(payload.projectIds) ? payload.projectIds.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
4565
+ return {
4566
+ status: "ambiguous_family_selection",
4567
+ candidateCount: typeof payload.candidateCount === "number" && Number.isFinite(payload.candidateCount) ? payload.candidateCount : projectIds.length,
4568
+ projectIds
4569
+ };
4570
+ }
4571
+ if (payload.projectId && payload.appId) {
4572
+ return {
4573
+ status: "resolved",
4574
+ projectId: String(payload.projectId),
4575
+ appId: String(payload.appId),
4576
+ upstreamAppId: String(payload.upstreamAppId ?? payload.appId),
4577
+ threadId: payload.threadId ? String(payload.threadId) : null
4578
+ };
4579
+ }
4580
+ return { status: "not_found" };
4581
+ }
4582
+ function throwAmbiguousFamilyError(params) {
4583
+ const familyCount = params.candidateCount || params.projectIds.length;
4584
+ const projectHint = params.projectIds.length > 0 ? ` Matching project ids: ${params.projectIds.join(", ")}.` : "";
4585
+ const remoteHint = params.remoteUrl ? ` (${params.remoteUrl})` : "";
4586
+ throw new RemixError("Multiple canonical Remix families already match this repository.", {
4587
+ exitCode: 2,
4588
+ hint: `Plain \`remix collab init\` cannot safely choose among ${familyCount} matching canonical families for repo fingerprint ${params.repoFingerprint}${remoteHint}.${projectHint} Run \`remix collab init --force-new\` to create a new canonical family, or continue from a checkout already bound to the intended family.`
4589
+ });
4590
+ }
4591
+ async function resolveOrEnsureLaneBinding(params) {
4592
+ const resolvePayload = {
4593
+ projectId: params.projectId ?? void 0,
4594
+ repoFingerprint: params.repoFingerprint,
4595
+ remoteUrl: params.remoteUrl ?? void 0,
4596
+ defaultBranch: params.defaultBranch ?? void 0,
4597
+ branchName: params.branchName
4598
+ };
4599
+ let lane = unwrapResponseObject(
4600
+ await params.api.resolveProjectLaneBinding(resolvePayload),
4601
+ "project lane binding"
4602
+ );
4603
+ if (lane.status !== "resolved") {
4604
+ lane = unwrapResponseObject(
4605
+ await params.api.ensureProjectLaneBinding({
4606
+ ...resolvePayload,
4607
+ seedAppId: params.seedAppId ?? void 0,
4608
+ seedHeadCommitHash: params.seedHeadCommitHash ?? void 0
4609
+ }),
4610
+ "project lane binding"
4611
+ );
4612
+ }
4613
+ return requireResolvedLaneBinding(lane, {
4614
+ branchName: params.branchName,
4615
+ operation: params.operation
4616
+ });
4617
+ }
4618
+ function branchBindingFromLane(lane, mode, fallback) {
4619
+ return {
4620
+ projectId: lane.projectId ?? fallback.projectId,
4621
+ currentAppId: lane.currentAppId ?? fallback.currentAppId,
4622
+ upstreamAppId: lane.upstreamAppId ?? fallback.upstreamAppId,
4623
+ threadId: lane.threadId ?? fallback.threadId,
4624
+ laneId: lane.laneId ?? null,
4625
+ bindingMode: mode
4626
+ };
4627
+ }
4628
+
4629
+ // src/application/collab/collabInitAsync.ts
4630
+ import { randomUUID as randomUUID4 } from "crypto";
4631
+
4632
+ // src/infrastructure/collab/drainerLog.ts
4633
+ import fs8 from "fs/promises";
4634
+ import path8 from "path";
4635
+ async function logDrainerEvent(jobId, event, fields = {}) {
4636
+ const entry = {
4637
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4638
+ jobId,
4639
+ event,
4640
+ ...fields
4641
+ };
4642
+ try {
4643
+ await fs8.mkdir(getLogsRoot(), { recursive: true });
4644
+ await fs8.appendFile(getDrainerLogPath(), `${JSON.stringify(entry)}
4645
+ `, "utf8");
4646
+ } catch {
4647
+ }
4648
+ }
4649
+ function drainerLogPath() {
4650
+ return getDrainerLogPath();
4651
+ }
4652
+
4653
+ // src/application/collab/collabInitAsync.ts
4654
+ async function resolvePendingAsyncInitResult(params) {
4655
+ if (shouldReplaceAsyncInitJobs({ forceNew: params.forceNew })) {
4656
+ for (const kind of ["init", "init_post"]) {
4657
+ const existing = await listAsyncJobsForRepo({ repoRoot: params.repoRoot, branchName: params.branchName, kind });
4658
+ for (const job of existing) {
4659
+ await deleteAsyncJob(job.id);
4660
+ await logDrainerEvent(job.id, "replaced", {
4661
+ kind,
4662
+ reason: "force_new",
4663
+ previousStatus: job.status,
4664
+ previousError: job.error
4665
+ });
4666
+ }
4667
+ }
4668
+ return null;
4669
+ }
4670
+ const pendingPost = await findPendingAsyncJob({ repoRoot: params.repoRoot, branchName: params.branchName, kind: "init_post" });
4671
+ const pending = pendingPost ?? await findPendingAsyncJob({ repoRoot: params.repoRoot, branchName: params.branchName, kind: "init" });
4672
+ if (!shouldReturnPendingAsyncInit({ forceNew: params.forceNew, pendingJobFound: Boolean(pending) }) || !pending) {
4673
+ return null;
4674
+ }
4675
+ const existingBinding = await readCollabBinding(params.repoRoot);
4676
+ if (existingBinding && existingBinding.currentAppId) {
4677
+ return {
4678
+ queued: true,
4679
+ jobId: pending.id,
4680
+ repoRoot: params.repoRoot,
4681
+ projectId: existingBinding.projectId ?? "",
4682
+ appId: existingBinding.currentAppId,
4683
+ upstreamAppId: existingBinding.upstreamAppId ?? existingBinding.currentAppId,
4684
+ dashboardUrl: buildDashboardAppUrl(existingBinding.currentAppId),
4685
+ bindingPath: getCollabBindingPath(params.repoRoot),
4686
+ repoFingerprint: existingBinding.repoFingerprint ?? params.repoFingerprint,
4687
+ bindingMode: existingBinding.bindingMode,
4688
+ createdCanonicalFamily: false,
4689
+ remoteUrl: existingBinding.remoteUrl ?? params.remoteUrl,
4690
+ defaultBranch: existingBinding.defaultBranch ?? params.defaultBranch ?? null,
4691
+ ...params.warnings.length > 0 ? { warnings: params.warnings } : {}
4692
+ };
4693
+ }
4694
+ return {
4695
+ queued: true,
4696
+ jobId: pending.id,
4697
+ repoRoot: params.repoRoot,
4698
+ projectId: "",
4699
+ appId: "",
4700
+ upstreamAppId: "",
4701
+ dashboardUrl: "",
4702
+ bindingPath: getCollabBindingPath(params.repoRoot),
4703
+ repoFingerprint: params.repoFingerprint,
4704
+ bindingMode: "lane",
4705
+ createdCanonicalFamily: false,
4706
+ remoteUrl: params.remoteUrl,
4707
+ defaultBranch: params.defaultBranch ?? null,
4708
+ ...params.warnings.length > 0 ? { warnings: params.warnings } : {}
4709
+ };
4710
+ }
4711
+ async function clearFailedAsyncInitJobs(params) {
4712
+ if (params.forceNew) return;
4713
+ const failed = await findFailedAsyncJob({ repoRoot: params.repoRoot, branchName: params.branchName, kind: "init" });
4714
+ if (failed) {
4715
+ await deleteAsyncJob(failed.id);
4716
+ }
4717
+ const failedPost = await findFailedAsyncJob({ repoRoot: params.repoRoot, branchName: params.branchName, kind: "init_post" });
4718
+ if (failedPost) {
4719
+ await deleteAsyncJob(failedPost.id);
4720
+ }
4721
+ }
4722
+ async function enqueueInitPostJob(params) {
4723
+ const job = await enqueueAsyncJob({
4724
+ id: randomUUID4(),
4725
+ kind: "init_post",
4726
+ status: "queued",
4727
+ repoRoot: params.repoRoot,
4728
+ repoFingerprint: params.repoFingerprint,
4729
+ branchName: params.branchName,
4730
+ laneId: params.laneId,
4731
+ retryCount: 0,
4732
+ error: null,
4733
+ idempotencyKey: null,
4734
+ payload: {
4735
+ appId: params.appId,
4736
+ syncPhaseStartedAt: params.syncPhaseStartedAt,
4737
+ syncPhaseFinishedAt: params.syncPhaseFinishedAt
4738
+ }
4739
+ });
4740
+ await logDrainerEvent(job.id, "submitted", {
4741
+ kind: "init_post",
4742
+ appId: params.appId,
4743
+ ["init.sync_phase_ms"]: params.syncPhaseFinishedAt - params.syncPhaseStartedAt
4744
+ });
4745
+ return job;
4746
+ }
4747
+
4748
+ // src/application/collab/collabInitImport.ts
4749
+ import fs10 from "fs/promises";
4750
+ import path9 from "path";
4751
+
4752
+ // src/shared/upload.ts
4753
+ import fs9 from "fs";
4754
+ import { PassThrough } from "stream";
4755
+ async function uploadPresigned(params) {
4756
+ const stats = await fs9.promises.stat(params.filePath).catch(() => null);
4757
+ if (!stats || !stats.isFile()) {
4758
+ throw new RemixError("Upload file not found.", { exitCode: 2 });
4759
+ }
4760
+ const totalBytes = stats.size;
4761
+ const fileStream = fs9.createReadStream(params.filePath);
4762
+ const pass = new PassThrough();
4763
+ let sentBytes = 0;
4764
+ fileStream.on("data", (chunk) => {
4765
+ sentBytes += chunk.length;
4766
+ params.onProgress?.({ sentBytes, totalBytes });
4767
+ });
4768
+ fileStream.on("error", (err) => pass.destroy(err));
4769
+ fileStream.pipe(pass);
4770
+ const response = await fetch(params.uploadUrl, {
4771
+ method: "PUT",
4772
+ headers: params.headers,
4773
+ body: pass,
4774
+ duplex: "half"
4775
+ });
4776
+ if (!response.ok) {
4777
+ const text = await response.text().catch(() => "");
4778
+ throw new RemixError("Upload failed.", {
4779
+ exitCode: 1,
4780
+ hint: `Status: ${response.status}
4781
+ ${text}`.trim() || null
4782
+ });
4783
+ }
4417
4784
  }
4418
- async function seedImportedInitBaseline(params) {
4419
- const appHead = unwrapResponseObject(
4420
- await params.api.getAppHead(params.currentAppId),
4421
- "app head"
4422
- );
4423
- const snapshot = await captureLocalSnapshot({
4424
- repoRoot: params.repoRoot,
4425
- repoFingerprint: params.repoFingerprint,
4426
- laneId: params.laneId,
4427
- branchName: params.branchName
4785
+
4786
+ // src/application/collab/collabInitImport.ts
4787
+ async function uploadInitBundle(params) {
4788
+ const bundleName = params.bundleName ?? path9.basename(params.bundlePath);
4789
+ const bundleSize = (await fs10.stat(params.bundlePath)).size;
4790
+ const presignResp = await params.api.presignImportUploadFirstParty({
4791
+ file: {
4792
+ name: bundleName,
4793
+ mimeType: "application/x-git-bundle",
4794
+ size: bundleSize,
4795
+ checksumSha256: params.bundleSha
4796
+ }
4428
4797
  });
4429
- await writeLocalBaseline({
4430
- repoRoot: params.repoRoot,
4431
- repoFingerprint: params.repoFingerprint,
4432
- laneId: params.laneId,
4433
- currentAppId: params.currentAppId,
4434
- branchName: params.branchName,
4435
- lastSnapshotId: snapshot.id,
4436
- lastSnapshotHash: snapshot.snapshotHash,
4437
- lastServerRevisionId: appHead.headRevisionId ?? null,
4438
- lastServerTreeHash: appHead.treeHash ?? null,
4439
- lastServerHeadHash: appHead.headCommitHash,
4440
- lastSeenLocalCommitHash: snapshot.localCommitHash
4798
+ const presign = unwrapResponseObject(presignResp, "upload");
4799
+ await uploadPresigned({
4800
+ uploadUrl: String(presign.uploadUrl),
4801
+ headers: presign.headers ?? {},
4802
+ filePath: params.bundlePath
4441
4803
  });
4442
- return "seeded";
4804
+ return String(presign.uploadId);
4805
+ }
4806
+ async function importUploadedInitBundle(params) {
4807
+ const importPayload = {
4808
+ uploadId: params.uploadId,
4809
+ appName: params.appName,
4810
+ platform: "generic",
4811
+ profile: params.profile ?? void 0,
4812
+ isPublic: false,
4813
+ branch: params.branch ?? void 0,
4814
+ remoteUrl: params.remoteUrl ?? void 0,
4815
+ defaultBranch: params.defaultBranch ?? void 0,
4816
+ repoFingerprint: params.repoFingerprint,
4817
+ headCommitHash: params.headCommitHash,
4818
+ forceNew: params.forceNew
4819
+ };
4820
+ if ("path" in params) {
4821
+ importPayload.path = params.path?.trim() || void 0;
4822
+ }
4823
+ const importResp = await params.api.importFromUploadFirstParty(importPayload);
4824
+ return unwrapResponseObject(importResp, "import");
4443
4825
  }
4826
+
4827
+ // src/application/collab/collabInit.ts
4444
4828
  async function collabInit(params) {
4445
4829
  return withRepoMutationLock(
4446
4830
  {
@@ -4457,7 +4841,7 @@ async function collabInit(params) {
4457
4841
  }
4458
4842
  const localBindingState = await readCollabBindingState(repoRoot, { persist: true });
4459
4843
  const hasExistingBinding = localBindingState != null && Object.keys(localBindingState.branchBindings ?? {}).length > 0;
4460
- if (params.forceNew || !hasExistingBinding) {
4844
+ if (shouldRequireCleanWorktreeForInit({ forceNew: params.forceNew, hasExistingBinding })) {
4461
4845
  await ensureCleanWorktree(repoRoot, "`remix collab init`");
4462
4846
  }
4463
4847
  const persistedRemoteUrl = normalizeGitRemote(localBindingState?.remoteUrl ?? null);
@@ -4469,59 +4853,17 @@ async function collabInit(params) {
4469
4853
  const repoFingerprint = localBindingState?.repoFingerprint ?? await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch });
4470
4854
  const repoSnapshot = await captureRepoSnapshot(repoRoot);
4471
4855
  if (params.asyncSubmit) {
4472
- if (params.forceNew) {
4473
- for (const kind of ["init", "init_post"]) {
4474
- const existing = await listAsyncJobsForRepo({ repoRoot, branchName, kind });
4475
- for (const job of existing) {
4476
- await deleteAsyncJob(job.id);
4477
- await logDrainerEvent(job.id, "replaced", {
4478
- kind,
4479
- reason: "force_new",
4480
- previousStatus: job.status,
4481
- previousError: job.error
4482
- });
4483
- }
4484
- }
4485
- } else {
4486
- const pendingPost = await findPendingAsyncJob({ repoRoot, branchName, kind: "init_post" });
4487
- const pending = pendingPost ?? await findPendingAsyncJob({ repoRoot, branchName, kind: "init" });
4488
- if (pending) {
4489
- const existingBinding = await readCollabBinding(repoRoot);
4490
- if (existingBinding && existingBinding.currentAppId) {
4491
- return {
4492
- queued: true,
4493
- jobId: pending.id,
4494
- repoRoot,
4495
- projectId: existingBinding.projectId ?? "",
4496
- appId: existingBinding.currentAppId,
4497
- upstreamAppId: existingBinding.upstreamAppId ?? existingBinding.currentAppId,
4498
- dashboardUrl: buildDashboardAppUrl(existingBinding.currentAppId),
4499
- bindingPath: getCollabBindingPath(repoRoot),
4500
- repoFingerprint: existingBinding.repoFingerprint ?? repoFingerprint,
4501
- bindingMode: existingBinding.bindingMode,
4502
- createdCanonicalFamily: false,
4503
- remoteUrl: existingBinding.remoteUrl ?? remoteUrl,
4504
- defaultBranch: existingBinding.defaultBranch ?? defaultBranch ?? null,
4505
- ...warnings.length > 0 ? { warnings } : {}
4506
- };
4507
- }
4508
- return {
4509
- queued: true,
4510
- jobId: pending.id,
4511
- repoRoot,
4512
- projectId: "",
4513
- appId: "",
4514
- upstreamAppId: "",
4515
- dashboardUrl: "",
4516
- bindingPath: getCollabBindingPath(repoRoot),
4517
- repoFingerprint,
4518
- bindingMode: "lane",
4519
- createdCanonicalFamily: false,
4520
- remoteUrl,
4521
- defaultBranch: defaultBranch ?? null,
4522
- ...warnings.length > 0 ? { warnings } : {}
4523
- };
4524
- }
4856
+ const pendingResult = await resolvePendingAsyncInitResult({
4857
+ forceNew: params.forceNew,
4858
+ repoRoot,
4859
+ branchName,
4860
+ repoFingerprint,
4861
+ remoteUrl,
4862
+ defaultBranch: defaultBranch ?? null,
4863
+ warnings
4864
+ });
4865
+ if (pendingResult) {
4866
+ return pendingResult;
4525
4867
  }
4526
4868
  }
4527
4869
  if (!params.forceNew && localBindingState?.explicitRootBinding && branchName) {
@@ -4613,7 +4955,7 @@ async function collabInit(params) {
4613
4955
  appId: boundCurrentAppId2,
4614
4956
  dashboardUrl: buildDashboardAppUrl(boundCurrentAppId2),
4615
4957
  upstreamAppId: boundUpstreamAppId2,
4616
- bindingPath: path9.join(repoRoot, ".remix", "config.json"),
4958
+ bindingPath: path10.join(repoRoot, ".remix", "config.json"),
4617
4959
  repoRoot,
4618
4960
  repoFingerprint,
4619
4961
  bindingMode: defaultBranch && branchName !== defaultBranch ? "lane" : "explicit_root",
@@ -4878,53 +5220,33 @@ async function collabInit(params) {
4878
5220
  }
4879
5221
  }
4880
5222
  if (params.asyncSubmit) {
4881
- if (!params.forceNew) {
4882
- const failed = await findFailedAsyncJob({ repoRoot, branchName, kind: "init" });
4883
- if (failed) {
4884
- await deleteAsyncJob(failed.id);
4885
- }
4886
- const failedPost = await findFailedAsyncJob({ repoRoot, branchName, kind: "init_post" });
4887
- if (failedPost) {
4888
- await deleteAsyncJob(failedPost.id);
4889
- }
4890
- }
5223
+ await clearFailedAsyncInitJobs({ forceNew: params.forceNew, repoRoot, branchName });
4891
5224
  const syncPhaseStartedAt = Date.now();
4892
5225
  const { bundlePath: bundleFilePath, headCommitHash: bundledHeadCommitHash } = await createGitBundle(
4893
5226
  repoRoot,
4894
5227
  "repository.bundle"
4895
5228
  );
4896
- const tmpBundleDir = path9.dirname(bundleFilePath);
5229
+ const tmpBundleDir = path10.dirname(bundleFilePath);
4897
5230
  try {
4898
5231
  const bundleSha2 = await sha256FileHex(bundleFilePath);
4899
- const bundleSize2 = (await fs10.stat(bundleFilePath)).size;
4900
- const presignResp2 = await params.api.presignImportUploadFirstParty({
4901
- file: {
4902
- name: "repository.bundle",
4903
- mimeType: "application/x-git-bundle",
4904
- size: bundleSize2,
4905
- checksumSha256: bundleSha2
4906
- }
4907
- });
4908
- const presign2 = unwrapResponseObject(presignResp2, "upload");
4909
- await uploadPresigned({
4910
- uploadUrl: String(presign2.uploadUrl),
4911
- headers: presign2.headers ?? {},
4912
- filePath: bundleFilePath
5232
+ const uploadId2 = await uploadInitBundle({
5233
+ api: params.api,
5234
+ bundlePath: bundleFilePath,
5235
+ bundleSha: bundleSha2,
5236
+ bundleName: "repository.bundle"
4913
5237
  });
4914
- const importResp2 = await params.api.importFromUploadFirstParty({
4915
- uploadId: String(presign2.uploadId),
4916
- appName: params.appName?.trim() || path9.basename(repoRoot),
4917
- platform: "generic",
5238
+ const imported2 = await importUploadedInitBundle({
5239
+ api: params.api,
5240
+ uploadId: uploadId2,
5241
+ appName: params.appName?.trim() || path10.basename(repoRoot),
4918
5242
  profile: params.profile ?? void 0,
4919
- isPublic: false,
4920
5243
  branch: defaultBranch && branchName && branchName !== defaultBranch ? defaultBranch : currentBranch ?? void 0,
4921
- remoteUrl: remoteUrl ?? void 0,
4922
- defaultBranch: defaultBranch ?? void 0,
5244
+ remoteUrl,
5245
+ defaultBranch: defaultBranch ?? null,
4923
5246
  repoFingerprint,
4924
5247
  headCommitHash: bundledHeadCommitHash,
4925
5248
  forceNew: params.forceNew
4926
5249
  });
4927
- const imported2 = unwrapResponseObject(importResp2, "import");
4928
5250
  const importedAppId = String(imported2.appId);
4929
5251
  let boundProjectId2 = String(imported2.projectId ?? "");
4930
5252
  let boundCurrentAppId2 = importedAppId;
@@ -5049,28 +5371,14 @@ async function collabInit(params) {
5049
5371
  });
5050
5372
  }
5051
5373
  const syncPhaseFinishedAt = Date.now();
5052
- const jobId = randomUUID4();
5053
- const job = await enqueueAsyncJob({
5054
- id: jobId,
5055
- kind: "init_post",
5056
- status: "queued",
5374
+ const job = await enqueueInitPostJob({
5057
5375
  repoRoot,
5058
5376
  repoFingerprint,
5059
5377
  branchName,
5060
5378
  laneId: boundLaneId2,
5061
- retryCount: 0,
5062
- error: null,
5063
- idempotencyKey: null,
5064
- payload: {
5065
- appId: boundCurrentAppId2,
5066
- syncPhaseStartedAt,
5067
- syncPhaseFinishedAt
5068
- }
5069
- });
5070
- await logDrainerEvent(job.id, "submitted", {
5071
- kind: "init_post",
5072
5379
  appId: boundCurrentAppId2,
5073
- ["init.sync_phase_ms"]: syncPhaseFinishedAt - syncPhaseStartedAt
5380
+ syncPhaseStartedAt,
5381
+ syncPhaseFinishedAt
5074
5382
  });
5075
5383
  return {
5076
5384
  queued: true,
@@ -5089,41 +5397,30 @@ async function collabInit(params) {
5089
5397
  ...warnings.length > 0 ? { warnings } : {}
5090
5398
  };
5091
5399
  } finally {
5092
- await fs10.rm(tmpBundleDir, { recursive: true, force: true }).catch(() => void 0);
5400
+ await fs11.rm(tmpBundleDir, { recursive: true, force: true }).catch(() => void 0);
5093
5401
  }
5094
5402
  }
5095
5403
  const { bundlePath, headCommitHash } = await createGitBundle(repoRoot, "repository.bundle");
5096
5404
  const bundleSha = await sha256FileHex(bundlePath);
5097
- const bundleSize = (await fs10.stat(bundlePath)).size;
5098
- const presignResp = await params.api.presignImportUploadFirstParty({
5099
- file: {
5100
- name: "repository.bundle",
5101
- mimeType: "application/x-git-bundle",
5102
- size: bundleSize,
5103
- checksumSha256: bundleSha
5104
- }
5105
- });
5106
- const presign = unwrapResponseObject(presignResp, "upload");
5107
- await uploadPresigned({
5108
- uploadUrl: String(presign.uploadUrl),
5109
- headers: presign.headers ?? {},
5110
- filePath: bundlePath
5405
+ const uploadId = await uploadInitBundle({
5406
+ api: params.api,
5407
+ bundlePath,
5408
+ bundleSha,
5409
+ bundleName: "repository.bundle"
5111
5410
  });
5112
- const importResp = await params.api.importFromUploadFirstParty({
5113
- uploadId: String(presign.uploadId),
5114
- appName: params.appName?.trim() || path9.basename(repoRoot),
5411
+ const imported = await importUploadedInitBundle({
5412
+ api: params.api,
5413
+ uploadId,
5414
+ appName: params.appName?.trim() || path10.basename(repoRoot),
5115
5415
  path: params.path?.trim() || void 0,
5116
- platform: "generic",
5117
5416
  profile: params.profile ?? void 0,
5118
- isPublic: false,
5119
5417
  branch: defaultBranch && branchName && branchName !== defaultBranch ? defaultBranch : currentBranch ?? void 0,
5120
- remoteUrl: remoteUrl ?? void 0,
5121
- defaultBranch: defaultBranch ?? void 0,
5418
+ remoteUrl,
5419
+ defaultBranch: defaultBranch ?? null,
5122
5420
  repoFingerprint,
5123
5421
  headCommitHash,
5124
5422
  forceNew: params.forceNew
5125
5423
  });
5126
- const imported = unwrapResponseObject(importResp, "import");
5127
5424
  const app = await pollAppImported(params.api, String(imported.appId));
5128
5425
  let boundProjectId = String(app.projectId);
5129
5426
  let boundCurrentAppId = String(app.id);
@@ -5323,10 +5620,10 @@ async function collabList(params) {
5323
5620
  }
5324
5621
 
5325
5622
  // src/application/collab/collabReconcile.ts
5326
- import fs11 from "fs/promises";
5623
+ import fs12 from "fs/promises";
5327
5624
  import os5 from "os";
5328
- import path10 from "path";
5329
- import { execa as execa3 } from "execa";
5625
+ import path11 from "path";
5626
+ import { execa as execa2 } from "execa";
5330
5627
 
5331
5628
  // src/application/collab/pendingFinalize.ts
5332
5629
  function hasPendingFinalize(summary) {
@@ -5336,6 +5633,50 @@ function buildPendingFinalizeHint() {
5336
5633
  return "Drain or await the local finalize queue first, then retry after the queued Remix turn finishes recording remotely.";
5337
5634
  }
5338
5635
 
5636
+ // src/application/collab/reconcileDecision.ts
5637
+ function classifyReconcileDelta(delta) {
5638
+ if (delta.status === "conflict_risk") {
5639
+ return {
5640
+ action: "metadata_conflict",
5641
+ message: "Local repository metadata conflicts with the bound Remix app.",
5642
+ exitCode: 2,
5643
+ hint: delta.warnings.join("\n") || "Run the command from the correct bound repository."
5644
+ };
5645
+ }
5646
+ if (delta.status === "base_unknown") {
5647
+ return {
5648
+ action: "base_unknown",
5649
+ message: "Reconcile cannot pull the newer server state from the last acknowledged baseline.",
5650
+ exitCode: 2,
5651
+ hint: "Run `remix collab init` to seed a fresh revision baseline for this checkout before retrying."
5652
+ };
5653
+ }
5654
+ if (delta.status !== "delta_ready" && delta.status !== "up_to_date") {
5655
+ return {
5656
+ action: "unsupported_delta",
5657
+ message: "Reconcile could not determine the server delta for this lane.",
5658
+ exitCode: 1,
5659
+ hint: delta.warnings.join("\n") || "Run `remix collab status` and retry once the lane is healthy."
5660
+ };
5661
+ }
5662
+ return { action: "ready" };
5663
+ }
5664
+ function classifyReconcileDetectedState(detected) {
5665
+ if (detected.status === "metadata_conflict" || detected.status === "branch_mismatch") {
5666
+ return { action: "realign", hint: detected.hint };
5667
+ }
5668
+ if (detected.repoState === "server_only_changed" || detected.repoState === "idle") return { action: "sync_instead" };
5669
+ if (detected.repoState === "both_changed") return { action: "reconcile" };
5670
+ if (detected.repoState === "external_local_base_changed") {
5671
+ return { action: "external_local_base_changed", hint: detected.hint };
5672
+ }
5673
+ if (detected.repoState === "local_only_changed") return { action: "local_only_finalize" };
5674
+ return { action: "not_reconcilable", hint: detected.hint };
5675
+ }
5676
+ function shouldApplyServerDeltaBeforeReplay(params) {
5677
+ return params.deltaStatus === "delta_ready" && params.diff.trim().length > 0;
5678
+ }
5679
+
5339
5680
  // src/application/collab/collabReconcile.ts
5340
5681
  async function reconcileBothChanged(params) {
5341
5682
  const repoRoot = await findGitRoot(params.cwd);
@@ -5391,22 +5732,11 @@ async function reconcileBothChanged(params) {
5391
5732
  ]);
5392
5733
  const appHead = unwrapResponseObject(appHeadResp, "app head");
5393
5734
  const delta = unwrapResponseObject(deltaResp, "app delta");
5394
- if (delta.status === "conflict_risk") {
5395
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
5396
- exitCode: 2,
5397
- hint: delta.warnings.join("\n") || "Run the command from the correct bound repository."
5398
- });
5399
- }
5400
- if (delta.status === "base_unknown") {
5401
- throw new RemixError("Reconcile cannot pull the newer server state from the last acknowledged baseline.", {
5402
- exitCode: 2,
5403
- hint: "Run `remix collab init` to seed a fresh revision baseline for this checkout before retrying."
5404
- });
5405
- }
5406
- if (delta.status !== "delta_ready" && delta.status !== "up_to_date") {
5407
- throw new RemixError("Reconcile could not determine the server delta for this lane.", {
5408
- exitCode: 1,
5409
- hint: delta.warnings.join("\n") || "Run `remix collab status` and retry once the lane is healthy."
5735
+ const deltaDecision = classifyReconcileDelta(delta);
5736
+ if (deltaDecision.action !== "ready") {
5737
+ throw new RemixError(deltaDecision.message, {
5738
+ exitCode: deltaDecision.exitCode,
5739
+ hint: deltaDecision.hint
5410
5740
  });
5411
5741
  }
5412
5742
  const preview = {
@@ -5460,15 +5790,15 @@ async function reconcileBothChanged(params) {
5460
5790
  const replay = await pollChangeStepReplay(params.api, binding.currentAppId, String(replayStart.id));
5461
5791
  const replayDiffResp = await params.api.getChangeStepReplayDiff(binding.currentAppId, replay.id);
5462
5792
  const replayDiff = unwrapResponseObject(replayDiffResp, "change step replay diff");
5463
- const tempRoot = await fs11.mkdtemp(path10.join(os5.tmpdir(), "remix-reconcile-"));
5793
+ const tempRoot = await fs12.mkdtemp(path11.join(os5.tmpdir(), "remix-reconcile-"));
5464
5794
  let serverHeadSnapshot = null;
5465
5795
  let mergedSnapshot = null;
5466
5796
  try {
5467
- const tempRepoRoot = path10.join(tempRoot, "repo");
5468
- await fs11.mkdir(tempRepoRoot, { recursive: true });
5469
- await execa3("git", ["init"], { cwd: tempRepoRoot, stderr: "ignore" });
5797
+ const tempRepoRoot = path11.join(tempRoot, "repo");
5798
+ await fs12.mkdir(tempRepoRoot, { recursive: true });
5799
+ await execa2("git", ["init"], { cwd: tempRepoRoot, stderr: "ignore" });
5470
5800
  await materializeLocalSnapshot(baseline.lastSnapshotId, tempRepoRoot);
5471
- if (delta.status === "delta_ready" && delta.diff.trim()) {
5801
+ if (shouldApplyServerDeltaBeforeReplay({ deltaStatus: delta.status, diff: delta.diff })) {
5472
5802
  await applyUnifiedDiffToWorktree(tempRepoRoot, delta.diff, "`remix collab reconcile`");
5473
5803
  }
5474
5804
  serverHeadSnapshot = await captureLocalSnapshot({
@@ -5487,7 +5817,7 @@ async function reconcileBothChanged(params) {
5487
5817
  branchName: binding.branchName
5488
5818
  });
5489
5819
  } finally {
5490
- await fs11.rm(tempRoot, { recursive: true, force: true }).catch(() => void 0);
5820
+ await fs12.rm(tempRoot, { recursive: true, force: true }).catch(() => void 0);
5491
5821
  }
5492
5822
  if (!serverHeadSnapshot || !mergedSnapshot) {
5493
5823
  throw new RemixError("Failed to materialize the reconciled local workspace.", { exitCode: 1 });
@@ -5548,25 +5878,30 @@ async function collabReconcile(params) {
5548
5878
  cwd: params.cwd,
5549
5879
  allowBranchMismatch: params.allowBranchMismatch
5550
5880
  });
5551
- if (detected.status === "metadata_conflict" || detected.status === "branch_mismatch") {
5881
+ const detectedDecision = classifyReconcileDetectedState({
5882
+ status: detected.status,
5883
+ repoState: detected.repoState,
5884
+ hint: detected.hint
5885
+ });
5886
+ if (detectedDecision.action === "realign") {
5552
5887
  throw new RemixError("Repository must be realigned before reconciliation.", {
5553
5888
  exitCode: 2,
5554
- hint: detected.hint
5889
+ hint: detectedDecision.hint
5555
5890
  });
5556
5891
  }
5557
- if (detected.repoState === "server_only_changed" || detected.repoState === "idle") {
5892
+ if (detectedDecision.action === "sync_instead") {
5558
5893
  return collabSync(params);
5559
5894
  }
5560
- if (detected.repoState === "both_changed") {
5895
+ if (detectedDecision.action === "reconcile") {
5561
5896
  return reconcileBothChanged(params);
5562
5897
  }
5563
- if (detected.repoState === "external_local_base_changed") {
5898
+ if (detectedDecision.action === "external_local_base_changed") {
5564
5899
  throw new RemixError("This checkout needs a local Remix revision baseline before reconciliation.", {
5565
5900
  exitCode: 2,
5566
- hint: detected.hint || "Run `remix collab init` or `remix collab sync` to seed the baseline."
5901
+ hint: detectedDecision.hint || "Run `remix collab init` or `remix collab sync` to seed the baseline."
5567
5902
  });
5568
5903
  }
5569
- if (detected.repoState === "local_only_changed") {
5904
+ if (detectedDecision.action === "local_only_finalize") {
5570
5905
  if (hasPendingFinalize(detected.pendingFinalize)) {
5571
5906
  throw new RemixError("Reconcile is not needed while queued Remix turn recording is still processing.", {
5572
5907
  exitCode: 2,
@@ -5690,7 +6025,85 @@ async function collabRemix(params) {
5690
6025
  };
5691
6026
  }
5692
6027
 
6028
+ // src/infrastructure/collab/localStateRepository.ts
6029
+ var defaultLocalStateRepository = {
6030
+ baselines: {
6031
+ read: readLocalBaseline,
6032
+ write: writeLocalBaseline,
6033
+ clear: clearLocalBaseline
6034
+ },
6035
+ snapshots: {
6036
+ inspect: inspectLocalSnapshot,
6037
+ capture: captureLocalSnapshot,
6038
+ read: readLocalSnapshot,
6039
+ materialize: materializeLocalSnapshot,
6040
+ restoreToWorktree: restoreLocalSnapshotToWorktree,
6041
+ diff: diffLocalSnapshots
6042
+ },
6043
+ asyncJobs: {
6044
+ enqueue: enqueueAsyncJob,
6045
+ read: readAsyncJob,
6046
+ list: listAsyncJobs,
6047
+ listForRepo: listAsyncJobsForRepo,
6048
+ findPending: findPendingAsyncJob,
6049
+ findFailed: findFailedAsyncJob,
6050
+ update: updateAsyncJob,
6051
+ delete: deleteAsyncJob,
6052
+ claim: claimAsyncJob,
6053
+ summarize: summarizeAsyncJobs,
6054
+ pruneTerminal: pruneTerminalAsyncJobs,
6055
+ getBundleFilePath: getAsyncJobBundleFilePath,
6056
+ await: awaitAsyncJob
6057
+ },
6058
+ finalizeJobs: {
6059
+ enqueue: enqueuePendingFinalizeJob,
6060
+ read: readPendingFinalizeJob,
6061
+ list: listPendingFinalizeJobs,
6062
+ update: updatePendingFinalizeJob,
6063
+ claim: claimPendingFinalizeJob,
6064
+ remove: removePendingFinalizeJob,
6065
+ forget: forgetPendingFinalizeJob,
6066
+ requeue: requeuePendingFinalizeJob,
6067
+ heartbeatLock: heartbeatJobLock,
6068
+ cleanStaleLocks: cleanStaleFinalizeJobLocks,
6069
+ prune: prunePendingFinalizeJobs,
6070
+ summarize: summarizePendingFinalizeJobs
6071
+ },
6072
+ bindings: {
6073
+ getPath: getCollabBindingPath,
6074
+ read: readCollabBinding,
6075
+ readState: readCollabBindingState,
6076
+ write: writeCollabBinding,
6077
+ writeSnapshot: writeCollabBindingSnapshot
6078
+ }
6079
+ };
6080
+ function createLocalStateRepository(overrides = {}) {
6081
+ return {
6082
+ baselines: {
6083
+ ...defaultLocalStateRepository.baselines,
6084
+ ...overrides.baselines
6085
+ },
6086
+ snapshots: {
6087
+ ...defaultLocalStateRepository.snapshots,
6088
+ ...overrides.snapshots
6089
+ },
6090
+ asyncJobs: {
6091
+ ...defaultLocalStateRepository.asyncJobs,
6092
+ ...overrides.asyncJobs
6093
+ },
6094
+ finalizeJobs: {
6095
+ ...defaultLocalStateRepository.finalizeJobs,
6096
+ ...overrides.finalizeJobs
6097
+ },
6098
+ bindings: {
6099
+ ...defaultLocalStateRepository.bindings,
6100
+ ...overrides.bindings
6101
+ }
6102
+ };
6103
+ }
6104
+
5693
6105
  // src/application/collab/collabRequestMerge.ts
6106
+ var localState = createLocalStateRepository();
5694
6107
  async function collabRequestMerge(params) {
5695
6108
  const repoRoot = await findGitRoot(params.cwd);
5696
6109
  const binding = await ensureActiveLaneBinding({
@@ -5698,14 +6111,24 @@ async function collabRequestMerge(params) {
5698
6111
  api: params.api,
5699
6112
  operation: "`remix collab request-merge`"
5700
6113
  });
5701
- if (!binding) throw new RemixError("Repository is not bound to Remix.", { exitCode: 2 });
5702
- const pendingFinalize = await summarizePendingFinalizeJobs({
6114
+ if (!binding) {
6115
+ const transition2 = planRequestMergeWorkflowTransition({ hasBinding: false, hasPendingFinalize: false });
6116
+ if (transition2.action === "require_binding") {
6117
+ throw new RemixError("Repository is not bound to Remix.", { exitCode: 2 });
6118
+ }
6119
+ throw new RemixError("Repository is not bound to Remix.", { exitCode: 2 });
6120
+ }
6121
+ const pendingFinalize = await localState.finalizeJobs.summarize({
5703
6122
  repoRoot,
5704
6123
  repoFingerprint: binding.repoFingerprint,
5705
6124
  currentAppId: binding.currentAppId,
5706
6125
  laneId: binding.laneId
5707
6126
  });
5708
- if (hasPendingFinalize(pendingFinalize)) {
6127
+ const transition = planRequestMergeWorkflowTransition({
6128
+ hasBinding: true,
6129
+ hasPendingFinalize: hasPendingFinalize(pendingFinalize)
6130
+ });
6131
+ if (transition.action === "require_finalize_drain") {
5709
6132
  throw new RemixError("Pending Remix turn recording still processing.", {
5710
6133
  exitCode: 2,
5711
6134
  hint: buildPendingFinalizeHint()
@@ -6125,9 +6548,34 @@ async function collabView(params) {
6125
6548
  }
6126
6549
 
6127
6550
  // src/application/collab/collabAsyncProcessing.ts
6128
- import fs12 from "fs/promises";
6129
- import path11 from "path";
6130
- var MAX_TRANSIENT_RETRIES = 5;
6551
+ import fs13 from "fs/promises";
6552
+ import path12 from "path";
6553
+
6554
+ // src/application/collab/collabDependencies.ts
6555
+ import { randomUUID as randomUUID5 } from "crypto";
6556
+ function isFullLocalStateRepository(value) {
6557
+ return typeof value.bindings?.getPath === "function" && typeof value.bindings.read === "function" && typeof value.bindings.readState === "function" && typeof value.bindings.write === "function" && typeof value.bindings.writeSnapshot === "function" && typeof value.baselines?.read === "function" && typeof value.baselines.write === "function" && typeof value.baselines.clear === "function" && typeof value.snapshots?.inspect === "function" && typeof value.snapshots.capture === "function" && typeof value.snapshots.read === "function" && typeof value.snapshots.materialize === "function" && typeof value.snapshots.restoreToWorktree === "function" && typeof value.snapshots.diff === "function" && typeof value.asyncJobs?.enqueue === "function" && typeof value.asyncJobs.read === "function" && typeof value.asyncJobs.list === "function" && typeof value.asyncJobs.listForRepo === "function" && typeof value.asyncJobs.findPending === "function" && typeof value.asyncJobs.findFailed === "function" && typeof value.asyncJobs.update === "function" && typeof value.asyncJobs.delete === "function" && typeof value.asyncJobs.claim === "function" && typeof value.asyncJobs.summarize === "function" && typeof value.asyncJobs.pruneTerminal === "function" && typeof value.asyncJobs.getBundleFilePath === "function" && typeof value.asyncJobs.await === "function" && typeof value.finalizeJobs?.enqueue === "function" && typeof value.finalizeJobs.read === "function" && typeof value.finalizeJobs.list === "function" && typeof value.finalizeJobs.update === "function" && typeof value.finalizeJobs.claim === "function" && typeof value.finalizeJobs.remove === "function" && typeof value.finalizeJobs.forget === "function" && typeof value.finalizeJobs.requeue === "function" && typeof value.finalizeJobs.heartbeatLock === "function" && typeof value.finalizeJobs.cleanStaleLocks === "function" && typeof value.finalizeJobs.prune === "function" && typeof value.finalizeJobs.summarize === "function";
6558
+ }
6559
+ function createCollabDependencies(overrides = {}) {
6560
+ const defaultClock = {
6561
+ now: () => Date.now(),
6562
+ isoNow: () => (/* @__PURE__ */ new Date()).toISOString()
6563
+ };
6564
+ const defaultTimers = {
6565
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
6566
+ };
6567
+ const localState3 = overrides.localState ? isFullLocalStateRepository(overrides.localState) ? overrides.localState : createLocalStateRepository(overrides.localState) : createLocalStateRepository();
6568
+ return {
6569
+ clock: { ...defaultClock, ...overrides.clock },
6570
+ ids: { randomUUID: randomUUID5, ...overrides.ids },
6571
+ timers: { ...defaultTimers, ...overrides.timers },
6572
+ git: overrides.git ?? createSystemGitAdapter(),
6573
+ localState: localState3,
6574
+ uploadPresigned: overrides.uploadPresigned ?? uploadPresigned
6575
+ };
6576
+ }
6577
+
6578
+ // src/application/collab/errorTaxonomy.ts
6131
6579
  var TRANSIENT_NETWORK_CODES = /* @__PURE__ */ new Set([
6132
6580
  "ECONNREFUSED",
6133
6581
  "ECONNRESET",
@@ -6139,9 +6587,10 @@ var TRANSIENT_NETWORK_CODES = /* @__PURE__ */ new Set([
6139
6587
  "EPIPE",
6140
6588
  "UND_ERR_SOCKET",
6141
6589
  "UND_ERR_CONNECT_TIMEOUT",
6142
- "UND_ERR_HEADERS_TIMEOUT"
6590
+ "UND_ERR_HEADERS_TIMEOUT",
6591
+ "UND_ERR_BODY_TIMEOUT"
6143
6592
  ]);
6144
- function formatError(error) {
6593
+ function formatWorkflowError(error) {
6145
6594
  if (error instanceof RemixError) {
6146
6595
  return [error.message, error.hint].filter(Boolean).join("\n\n") || error.message || "Unknown error.";
6147
6596
  }
@@ -6167,8 +6616,15 @@ function readStringCandidate(error, key) {
6167
6616
  if (typeof value === "string" && value.length > 0) return value;
6168
6617
  return null;
6169
6618
  }
6170
- function classifyError(error) {
6171
- if (error instanceof RemixError) return "jobSpecific";
6619
+ function buildClassification(category, retryable) {
6620
+ return {
6621
+ category,
6622
+ retryable,
6623
+ terminal: !retryable,
6624
+ disposition: retryable ? "transient" : "jobSpecific"
6625
+ };
6626
+ }
6627
+ function classifyWorkflowError(error) {
6172
6628
  const candidates = [error];
6173
6629
  if (error && typeof error === "object" && "cause" in error) {
6174
6630
  candidates.push(error.cause);
@@ -6176,31 +6632,42 @@ function classifyError(error) {
6176
6632
  for (const candidate of candidates) {
6177
6633
  const status = readNumberCandidate(candidate, "status") ?? readNumberCandidate(candidate, "statusCode");
6178
6634
  if (status !== null) {
6179
- if (status === 401 || status === 403) return "transient";
6180
- if (status >= 500 && status < 600) return "transient";
6181
- if (status >= 400 && status < 500) return "jobSpecific";
6635
+ if (status === 401 || status === 403) return buildClassification("transient_backend", true);
6636
+ if (status >= 500 && status < 600) return buildClassification("transient_backend", true);
6637
+ if (status >= 400 && status < 500) return buildClassification("backend_contract_failure", false);
6182
6638
  }
6183
6639
  const code = readStringCandidate(candidate, "code");
6184
- if (code && TRANSIENT_NETWORK_CODES.has(code)) return "transient";
6185
- if (code === "ENOENT" || code === "EACCES") return "jobSpecific";
6640
+ if (code && TRANSIENT_NETWORK_CODES.has(code)) return buildClassification("transient_network", true);
6641
+ if (code === "ENOENT" || code === "EACCES") return buildClassification("persisted_state_corruption", false);
6186
6642
  const name = readStringCandidate(candidate, "name");
6187
- if (name === "AbortError" || name === "TimeoutError") return "transient";
6188
- if (name === "SyntaxError") return "jobSpecific";
6643
+ if (name === "AbortError" || name === "TimeoutError") return buildClassification("transient_network", true);
6644
+ if (name === "SyntaxError") return buildClassification("backend_contract_failure", false);
6189
6645
  }
6190
- return "jobSpecific";
6646
+ if (error instanceof RemixError) return buildClassification("user_action_needed", false);
6647
+ return buildClassification("backend_contract_failure", false);
6648
+ }
6649
+
6650
+ // src/application/collab/workflowEvents.ts
6651
+ async function logDrainerWorkflowEvent(jobId, event, fields = {}) {
6652
+ await logDrainerEvent(jobId, event, fields);
6191
6653
  }
6654
+
6655
+ // src/application/collab/collabAsyncProcessing.ts
6656
+ var MAX_TRANSIENT_RETRIES = 5;
6657
+ var collabDependencies = createCollabDependencies();
6658
+ var localState2 = collabDependencies.localState;
6192
6659
  async function handleProcessError(job, error) {
6193
- const message = formatError(error);
6194
- const kind = classifyError(error);
6660
+ const message = formatWorkflowError(error);
6661
+ const { disposition: kind } = classifyWorkflowError(error);
6195
6662
  if (kind === "transient") {
6196
6663
  const nextRetry = (job.retryCount ?? 0) + 1;
6197
6664
  if (nextRetry < MAX_TRANSIENT_RETRIES) {
6198
- await updateAsyncJob(job.id, {
6665
+ await localState2.asyncJobs.update(job.id, {
6199
6666
  status: "queued",
6200
6667
  retryCount: nextRetry,
6201
6668
  error: message
6202
6669
  });
6203
- await logDrainerEvent(job.id, "retrying", {
6670
+ await logDrainerWorkflowEvent(job.id, "retrying", {
6204
6671
  kind: job.kind,
6205
6672
  retryCount: nextRetry,
6206
6673
  error: message
@@ -6208,41 +6675,41 @@ async function handleProcessError(job, error) {
6208
6675
  return { status: "retry_scheduled", jobId: job.id, error: message, retryCount: nextRetry };
6209
6676
  }
6210
6677
  const finalMessage = `exceeded retry limit (${MAX_TRANSIENT_RETRIES} attempts). Last error: ${message}`;
6211
- await updateAsyncJob(job.id, { status: "failed", error: finalMessage, retryCount: nextRetry });
6212
- await logDrainerEvent(job.id, "failed", { kind: job.kind, error: finalMessage, retryCount: nextRetry });
6678
+ await localState2.asyncJobs.update(job.id, { status: "failed", error: finalMessage, retryCount: nextRetry });
6679
+ await logDrainerWorkflowEvent(job.id, "failed", { kind: job.kind, error: finalMessage, retryCount: nextRetry });
6213
6680
  return { status: "failed", jobId: job.id, error: finalMessage };
6214
6681
  }
6215
- await updateAsyncJob(job.id, { status: "failed", error: message });
6216
- await logDrainerEvent(job.id, "failed", { kind: job.kind, error: message });
6682
+ await localState2.asyncJobs.update(job.id, { status: "failed", error: message });
6683
+ await logDrainerWorkflowEvent(job.id, "failed", { kind: job.kind, error: message });
6217
6684
  return { status: "failed", jobId: job.id, error: message };
6218
6685
  }
6219
6686
  async function processInitJob(job, api) {
6220
6687
  if (job.kind !== "init") return { status: "skipped", jobId: job.id };
6221
- const startedAt = Date.now();
6688
+ const startedAt = collabDependencies.clock.now();
6222
6689
  try {
6223
- await updateAsyncJob(job.id, { status: "submitting", error: null });
6224
- await logDrainerEvent(job.id, "claimed", { kind: "init" });
6225
- const bundleStat = await fs12.stat(job.payload.bundlePath);
6690
+ await localState2.asyncJobs.update(job.id, { status: "submitting", error: null });
6691
+ await logDrainerWorkflowEvent(job.id, "claimed", { kind: "init" });
6692
+ const bundleStat = await fs13.stat(job.payload.bundlePath);
6226
6693
  const presignResp = await api.presignImportUploadFirstParty({
6227
6694
  file: {
6228
- name: path11.basename(job.payload.bundlePath),
6695
+ name: path12.basename(job.payload.bundlePath),
6229
6696
  mimeType: "application/x-git-bundle",
6230
6697
  size: bundleStat.size,
6231
6698
  checksumSha256: job.payload.bundleSha256
6232
6699
  }
6233
6700
  });
6234
6701
  const presign = unwrapResponseObject(presignResp, "upload");
6235
- await updateAsyncJob(job.id, { status: "uploading" });
6236
- await logDrainerEvent(job.id, "uploading", { kind: "init" });
6237
- await uploadPresigned({
6702
+ await localState2.asyncJobs.update(job.id, { status: "uploading" });
6703
+ await logDrainerWorkflowEvent(job.id, "uploading", { kind: "init" });
6704
+ await collabDependencies.uploadPresigned({
6238
6705
  uploadUrl: String(presign.uploadUrl),
6239
6706
  headers: presign.headers ?? {},
6240
6707
  filePath: job.payload.bundlePath
6241
6708
  });
6242
- await updateAsyncJob(job.id, { status: "server_processing" });
6709
+ await localState2.asyncJobs.update(job.id, { status: "server_processing" });
6243
6710
  const importResp = await api.importFromUploadFirstParty({
6244
6711
  uploadId: String(presign.uploadId),
6245
- appName: job.payload.appName?.trim() || path11.basename(job.repoRoot),
6712
+ appName: job.payload.appName?.trim() || path12.basename(job.repoRoot),
6246
6713
  platform: "generic",
6247
6714
  isPublic: false,
6248
6715
  branch: job.payload.defaultBranch && job.branchName && job.branchName !== job.payload.defaultBranch ? job.payload.defaultBranch : job.branchName ?? void 0,
@@ -6252,11 +6719,14 @@ async function processInitJob(job, api) {
6252
6719
  headCommitHash: job.payload.headCommitHash
6253
6720
  });
6254
6721
  const imported = unwrapResponseObject(importResp, "import");
6255
- await logDrainerEvent(job.id, "server_accepted", {
6722
+ await logDrainerWorkflowEvent(job.id, "server_accepted", {
6256
6723
  kind: "init",
6257
6724
  appId: String(imported.appId ?? "")
6258
6725
  });
6259
- const app = await pollAppReady(api, String(imported.appId));
6726
+ const app = await pollAppReady(api, String(imported.appId), {
6727
+ clock: collabDependencies.clock,
6728
+ timers: collabDependencies.timers
6729
+ });
6260
6730
  let boundProjectId = String(app.projectId);
6261
6731
  let boundCurrentAppId = String(app.id);
6262
6732
  let boundUpstreamAppId = String(app.id);
@@ -6316,7 +6786,10 @@ async function processInitJob(job, api) {
6316
6786
  }
6317
6787
  }
6318
6788
  if (boundCurrentAppId) {
6319
- const readyApp = await pollAppReady(api, boundCurrentAppId);
6789
+ const readyApp = await pollAppReady(api, boundCurrentAppId, {
6790
+ clock: collabDependencies.clock,
6791
+ timers: collabDependencies.timers
6792
+ });
6320
6793
  boundProjectId = String(readyApp.projectId ?? boundProjectId);
6321
6794
  boundThreadId = readyApp.threadId ? String(readyApp.threadId) : boundThreadId;
6322
6795
  }
@@ -6339,7 +6812,7 @@ async function processInitJob(job, api) {
6339
6812
  bindingMode: "lane"
6340
6813
  };
6341
6814
  }
6342
- await writeCollabBindingSnapshot({
6815
+ await localState2.bindings.writeSnapshot({
6343
6816
  repoRoot,
6344
6817
  repoFingerprint: repoFingerprint ?? "",
6345
6818
  remoteUrl,
@@ -6349,7 +6822,7 @@ async function processInitJob(job, api) {
6349
6822
  });
6350
6823
  } else {
6351
6824
  if (canonicalLane && defaultBranch && branchName && branchName !== defaultBranch) {
6352
- await writeCollabBinding(repoRoot, {
6825
+ await localState2.bindings.write(repoRoot, {
6353
6826
  projectId: canonicalLane.projectId ?? null,
6354
6827
  currentAppId: canonicalLane.currentAppId ?? boundCurrentAppId,
6355
6828
  upstreamAppId: canonicalLane.upstreamAppId ?? canonicalLane.currentAppId ?? boundCurrentAppId,
@@ -6367,7 +6840,7 @@ async function processInitJob(job, api) {
6367
6840
  bindingMode: "explicit_root"
6368
6841
  });
6369
6842
  }
6370
- await writeCollabBinding(repoRoot, {
6843
+ await localState2.bindings.write(repoRoot, {
6371
6844
  projectId: boundProjectId,
6372
6845
  currentAppId: boundCurrentAppId,
6373
6846
  upstreamAppId: boundUpstreamAppId,
@@ -6388,14 +6861,14 @@ async function processInitJob(job, api) {
6388
6861
  currentAppId: boundCurrentAppId,
6389
6862
  branchName
6390
6863
  });
6391
- await logDrainerEvent(job.id, "server_completed", {
6864
+ await logDrainerWorkflowEvent(job.id, "server_completed", {
6392
6865
  kind: "init",
6393
6866
  appId: boundCurrentAppId,
6394
6867
  dashboardUrl: buildDashboardAppUrl(boundCurrentAppId),
6395
- durationMs: Date.now() - startedAt
6868
+ durationMs: collabDependencies.clock.now() - startedAt
6396
6869
  });
6397
- await updateAsyncJob(job.id, { status: "completed", error: null });
6398
- await logDrainerEvent(job.id, "completed", { kind: "init", durationMs: Date.now() - startedAt });
6870
+ await localState2.asyncJobs.update(job.id, { status: "completed", error: null });
6871
+ await logDrainerWorkflowEvent(job.id, "completed", { kind: "init", durationMs: collabDependencies.clock.now() - startedAt });
6399
6872
  return { status: "completed", jobId: job.id };
6400
6873
  } catch (error) {
6401
6874
  return await handleProcessError(job, error);
@@ -6403,16 +6876,19 @@ async function processInitJob(job, api) {
6403
6876
  }
6404
6877
  async function processInitPostJob(job, api) {
6405
6878
  if (job.kind !== "init_post") return { status: "skipped", jobId: job.id };
6406
- const startedAt = Date.now();
6879
+ const startedAt = collabDependencies.clock.now();
6407
6880
  const syncPhaseMs = Math.max(0, job.payload.syncPhaseFinishedAt - job.payload.syncPhaseStartedAt);
6408
6881
  try {
6409
- await updateAsyncJob(job.id, { status: "server_processing", error: null });
6410
- await logDrainerEvent(job.id, "claimed", {
6882
+ await localState2.asyncJobs.update(job.id, { status: "server_processing", error: null });
6883
+ await logDrainerWorkflowEvent(job.id, "claimed", {
6411
6884
  kind: "init_post",
6412
6885
  appId: job.payload.appId,
6413
6886
  syncPhaseMs
6414
6887
  });
6415
- await pollAppReady(api, job.payload.appId);
6888
+ await pollAppReady(api, job.payload.appId, {
6889
+ clock: collabDependencies.clock,
6890
+ timers: collabDependencies.timers
6891
+ });
6416
6892
  await seedImportedInitBaseline({
6417
6893
  api,
6418
6894
  repoRoot: job.repoRoot,
@@ -6421,9 +6897,9 @@ async function processInitPostJob(job, api) {
6421
6897
  currentAppId: job.payload.appId,
6422
6898
  branchName: job.branchName
6423
6899
  });
6424
- const asyncPhaseMs = Date.now() - startedAt;
6425
- await updateAsyncJob(job.id, { status: "completed", error: null });
6426
- await logDrainerEvent(job.id, "completed", {
6900
+ const asyncPhaseMs = collabDependencies.clock.now() - startedAt;
6901
+ await localState2.asyncJobs.update(job.id, { status: "completed", error: null });
6902
+ await logDrainerWorkflowEvent(job.id, "completed", {
6427
6903
  kind: "init_post",
6428
6904
  appId: job.payload.appId,
6429
6905
  durationMs: asyncPhaseMs,
@@ -6437,10 +6913,10 @@ async function processInitPostJob(job, api) {
6437
6913
  } catch (error) {
6438
6914
  const outcome = await handleProcessError(job, error);
6439
6915
  if (outcome.status === "failed") {
6440
- const bindingPath = getCollabBindingPath(job.repoRoot);
6916
+ const bindingPath = localState2.bindings.getPath(job.repoRoot);
6441
6917
  try {
6442
- await fs12.unlink(bindingPath);
6443
- await logDrainerEvent(job.id, "binding_cleared", {
6918
+ await fs13.unlink(bindingPath);
6919
+ await logDrainerWorkflowEvent(job.id, "binding_cleared", {
6444
6920
  kind: "init_post",
6445
6921
  appId: job.payload.appId,
6446
6922
  repoRoot: job.repoRoot,
@@ -6448,105 +6924,23 @@ async function processInitPostJob(job, api) {
6448
6924
  reason: "init_post_terminal_failure"
6449
6925
  });
6450
6926
  } catch (cleanupError) {
6451
- await logDrainerEvent(job.id, "binding_cleared", {
6927
+ await logDrainerWorkflowEvent(job.id, "binding_cleared", {
6452
6928
  kind: "init_post",
6453
6929
  appId: job.payload.appId,
6454
6930
  repoRoot: job.repoRoot,
6455
6931
  bindingPath,
6456
6932
  reason: "init_post_terminal_failure",
6457
- cleanupError: formatError(cleanupError)
6933
+ cleanupError: formatWorkflowError(cleanupError)
6458
6934
  });
6459
6935
  }
6460
6936
  }
6461
6937
  return outcome;
6462
6938
  }
6463
6939
  }
6464
- async function processReAnchorJob(job, api) {
6465
- if (job.kind !== "re_anchor") return { status: "skipped", jobId: job.id };
6466
- const startedAt = Date.now();
6467
- try {
6468
- await updateAsyncJob(job.id, { status: "submitting", error: null });
6469
- await logDrainerEvent(job.id, "claimed", { kind: "re_anchor" });
6470
- const preflightResp = await api.preflightAppReconcile(job.payload.appId, {
6471
- localHeadCommitHash: job.payload.localHeadCommitHash,
6472
- repoFingerprint: job.repoFingerprint ?? void 0
6473
- });
6474
- const preflight = unwrapResponseObject(preflightResp, "reconcile preflight");
6475
- if (preflight.status === "metadata_conflict") {
6476
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
6477
- exitCode: 2,
6478
- hint: preflight.warnings.join("\n") || null
6479
- });
6480
- }
6481
- let anchoredServerHeadHash = preflight.targetHeadCommitHash;
6482
- if (preflight.status === "ready_to_reconcile") {
6483
- const bundleStat = await fs12.stat(job.payload.bundlePath);
6484
- const presignResp = await api.presignImportUploadFirstParty({
6485
- file: {
6486
- name: path11.basename(job.payload.bundlePath),
6487
- mimeType: "application/x-git-bundle",
6488
- size: bundleStat.size,
6489
- checksumSha256: job.payload.bundleSha256
6490
- }
6491
- });
6492
- const uploadTarget = unwrapResponseObject(presignResp, "import upload target");
6493
- await updateAsyncJob(job.id, { status: "uploading" });
6494
- await logDrainerEvent(job.id, "uploading", { kind: "re_anchor" });
6495
- await uploadPresigned({
6496
- uploadUrl: String(uploadTarget.uploadUrl),
6497
- filePath: job.payload.bundlePath,
6498
- headers: uploadTarget.headers ?? {}
6499
- });
6500
- await updateAsyncJob(job.id, { status: "server_processing" });
6501
- const startResp = await api.startAppReconcile(job.payload.appId, {
6502
- uploadId: String(uploadTarget.uploadId),
6503
- localHeadCommitHash: job.payload.localHeadCommitHash,
6504
- repoFingerprint: job.repoFingerprint ?? void 0,
6505
- idempotencyKey: buildDeterministicIdempotencyKey({
6506
- kind: "collab_re_anchor_v1",
6507
- appId: job.payload.appId,
6508
- localHeadCommitHash: job.payload.localHeadCommitHash,
6509
- targetHeadCommitHash: job.payload.targetHeadCommitHash
6510
- })
6511
- });
6512
- const started = unwrapResponseObject(startResp, "reconcile");
6513
- await logDrainerEvent(job.id, "server_accepted", { kind: "re_anchor", appId: job.payload.appId });
6514
- const reconcile = await pollReconcile(api, job.payload.appId, started.id);
6515
- anchoredServerHeadHash = reconcile.reconciledHeadCommitHash ?? reconcile.targetHeadCommitHash ?? preflight.targetHeadCommitHash;
6516
- }
6517
- const snapshot = await captureLocalSnapshot({
6518
- repoRoot: job.repoRoot,
6519
- repoFingerprint: job.repoFingerprint,
6520
- laneId: job.laneId,
6521
- branchName: job.branchName
6522
- });
6523
- await writeLocalBaseline({
6524
- repoRoot: job.repoRoot,
6525
- repoFingerprint: job.repoFingerprint,
6526
- laneId: job.laneId,
6527
- currentAppId: job.payload.appId,
6528
- branchName: job.branchName,
6529
- lastSnapshotId: snapshot.id,
6530
- lastSnapshotHash: snapshot.snapshotHash,
6531
- lastServerHeadHash: anchoredServerHeadHash,
6532
- lastSeenLocalCommitHash: snapshot.localCommitHash
6533
- });
6534
- await logDrainerEvent(job.id, "server_completed", {
6535
- kind: "re_anchor",
6536
- appId: job.payload.appId,
6537
- durationMs: Date.now() - startedAt
6538
- });
6539
- await updateAsyncJob(job.id, { status: "completed", error: null });
6540
- await logDrainerEvent(job.id, "completed", { kind: "re_anchor", durationMs: Date.now() - startedAt });
6541
- return { status: "completed", jobId: job.id };
6542
- } catch (error) {
6543
- return await handleProcessError(job, error);
6544
- }
6545
- }
6546
6940
  async function collabInitProcess(jobId, opts) {
6547
- const claim = await claimAsyncJob(jobId);
6941
+ const claim = await localState2.asyncJobs.claim(jobId);
6548
6942
  if (!claim) {
6549
- const job = await readAsyncJob(jobId);
6943
+ const job = await localState2.asyncJobs.read(jobId);
6550
6944
  if (!job) return { status: "skipped", jobId };
6551
6945
  return { status: "skipped", jobId };
6552
6946
  }
@@ -6558,9 +6952,9 @@ async function collabInitProcess(jobId, opts) {
6558
6952
  }
6559
6953
  }
6560
6954
  async function collabInitPostProcess(jobId, opts) {
6561
- const claim = await claimAsyncJob(jobId);
6955
+ const claim = await localState2.asyncJobs.claim(jobId);
6562
6956
  if (!claim) {
6563
- const job = await readAsyncJob(jobId);
6957
+ const job = await localState2.asyncJobs.read(jobId);
6564
6958
  if (!job) return { status: "skipped", jobId };
6565
6959
  return { status: "skipped", jobId };
6566
6960
  }
@@ -6571,25 +6965,11 @@ async function collabInitPostProcess(jobId, opts) {
6571
6965
  await claim.release();
6572
6966
  }
6573
6967
  }
6574
- async function collabReAnchorProcess(jobId, opts) {
6575
- const claim = await claimAsyncJob(jobId);
6576
- if (!claim) {
6577
- const job = await readAsyncJob(jobId);
6578
- if (!job) return { status: "skipped", jobId };
6579
- return { status: "skipped", jobId };
6580
- }
6581
- try {
6582
- if (claim.job.kind !== "re_anchor") return { status: "skipped", jobId };
6583
- return await processReAnchorJob(claim.job, opts.api);
6584
- } finally {
6585
- await claim.release();
6586
- }
6587
- }
6588
6968
  async function acquireDrainerPidLock() {
6589
6969
  const pidPath = getDrainerPidPath();
6590
- await fs12.mkdir(path11.dirname(pidPath), { recursive: true });
6970
+ await fs13.mkdir(path12.dirname(pidPath), { recursive: true });
6591
6971
  try {
6592
- const existing = await fs12.readFile(pidPath, "utf8").catch(() => "");
6972
+ const existing = await fs13.readFile(pidPath, "utf8").catch(() => "");
6593
6973
  const existingPid = parseInt(existing.trim(), 10);
6594
6974
  if (Number.isFinite(existingPid) && existingPid > 0 && existingPid !== process.pid) {
6595
6975
  try {
@@ -6599,13 +6979,13 @@ async function acquireDrainerPidLock() {
6599
6979
  if (error?.code !== "ESRCH") return null;
6600
6980
  }
6601
6981
  }
6602
- await fs12.writeFile(pidPath, String(process.pid), "utf8");
6982
+ await fs13.writeFile(pidPath, String(process.pid), "utf8");
6603
6983
  return {
6604
6984
  release: async () => {
6605
6985
  try {
6606
- const current = (await fs12.readFile(pidPath, "utf8")).trim();
6986
+ const current = (await fs13.readFile(pidPath, "utf8")).trim();
6607
6987
  if (current === String(process.pid)) {
6608
- await fs12.unlink(pidPath).catch(() => void 0);
6988
+ await fs13.unlink(pidPath).catch(() => void 0);
6609
6989
  }
6610
6990
  } catch {
6611
6991
  }
@@ -6618,31 +6998,30 @@ async function acquireDrainerPidLock() {
6618
6998
  async function drainAsyncJobs(opts) {
6619
6999
  const lockHandle = opts.respectPidLock === false ? null : await acquireDrainerPidLock();
6620
7000
  if (opts.respectPidLock !== false && !lockHandle) return [];
6621
- const outcomes = [];
6622
7001
  try {
6623
- try {
6624
- await pruneTerminalAsyncJobs();
6625
- } catch {
6626
- }
6627
- const jobs = await listAsyncJobs();
6628
- const drainable = jobs.filter(
6629
- (job) => job.status !== "completed" && job.status !== "failed"
6630
- );
6631
- for (const job of drainable) {
6632
- let outcome;
6633
- if (job.kind === "init") {
6634
- outcome = await collabInitProcess(job.id, { api: opts.api });
6635
- } else if (job.kind === "init_post") {
6636
- outcome = await collabInitPostProcess(job.id, { api: opts.api });
6637
- } else {
6638
- outcome = await collabReAnchorProcess(job.id, { api: opts.api });
6639
- }
6640
- outcomes.push(outcome);
6641
- }
7002
+ return await drainClaimedJobs({
7003
+ prune: async () => {
7004
+ try {
7005
+ await localState2.asyncJobs.pruneTerminal();
7006
+ } catch {
7007
+ }
7008
+ },
7009
+ list: () => localState2.asyncJobs.list(),
7010
+ isTerminal: (job) => planAsyncJobDrainTransition(job).terminal,
7011
+ claim: async (job) => ({
7012
+ job,
7013
+ /* v8 ignore next -- async job processors perform their own claim/release cycle. */
7014
+ release: async () => void 0
7015
+ }),
7016
+ process: async ({ job }) => {
7017
+ const transition = planAsyncJobDrainTransition(job);
7018
+ return transition.action === "process_init" ? collabInitProcess(job.id, { api: opts.api }) : collabInitPostProcess(job.id, { api: opts.api });
7019
+ },
7020
+ onError: "throw"
7021
+ });
6642
7022
  } finally {
6643
7023
  if (lockHandle) await lockHandle.release();
6644
7024
  }
6645
- return outcomes;
6646
7025
  }
6647
7026
  export {
6648
7027
  FINALIZE_JOB_LOCK_STALE_MS,