sidecarsync 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +205 -39
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -216,12 +216,15 @@ function utcTimestamp() {
216
216
  function nowIso() {
217
217
  return new Date().toISOString();
218
218
  }
219
- var SidecarError;
219
+ var DIRTY_CHECKOUT_EXIT = 75, SidecarError;
220
220
  var init_util = __esm(() => {
221
221
  SidecarError = class SidecarError extends Error {
222
- constructor(message) {
222
+ reason;
223
+ stage;
224
+ constructor(message, options = {}) {
223
225
  super(message);
224
226
  this.name = "SidecarError";
227
+ this.reason = options.reason;
225
228
  }
226
229
  };
227
230
  });
@@ -1402,7 +1405,7 @@ function isDirty(repo) {
1402
1405
  }
1403
1406
  function ensureClean(repo) {
1404
1407
  if (isDirty(repo))
1405
- throw new SidecarError("sidecar checkout has uncommitted changes");
1408
+ throw new SidecarError("sidecar checkout has uncommitted changes", { reason: "dirty-checkout" });
1406
1409
  }
1407
1410
  function sidecarIdentity() {
1408
1411
  return { name: currentUser(), email: `${slug(currentUser())}@${slug(currentHost())}.local` };
@@ -3812,7 +3815,7 @@ function inboxBranchPrefix(template) {
3812
3815
  const slashIndex = staticPrefix.lastIndexOf("/");
3813
3816
  return slashIndex === -1 ? staticPrefix : staticPrefix.slice(0, slashIndex + 1);
3814
3817
  }
3815
- var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", SOFT_SYNC_ENV = "SIDECAR_SYNC_SOFT", LOCAL_SYNC_ENV = "SIDECAR_SYNC_LOCAL", PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork", REVIEW_MODES, DEFAULT_REVIEW_TIMEOUT_SECONDS, CONFIG_VERSION = 1, CONFIG_KEYS, failuresArePrivate = false, PUBLIC_REFS = "refs/sidecar-public/";
3818
+ var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", SOFT_SYNC_ENV = "SIDECAR_SYNC_SOFT", LOCAL_SYNC_ENV = "SIDECAR_SYNC_LOCAL", DEFAULT_DEBOUNCE_SECONDS = 60, PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork", REVIEW_MODES, DEFAULT_REVIEW_TIMEOUT_SECONDS, CONFIG_VERSION = 1, CONFIG_KEYS, failuresArePrivate = false, PUBLIC_REFS = "refs/sidecar-public/";
3816
3819
  var init_config = __esm(() => {
3817
3820
  init_dist();
3818
3821
  init_util();
@@ -4200,6 +4203,37 @@ function syncLockIsStale(lockDir) {
4200
4203
  return error.code !== "EPERM";
4201
4204
  }
4202
4205
  }
4206
+ function reviewDueDir() {
4207
+ return path6.join(sidecarStateDir(), "review-due");
4208
+ }
4209
+ function reviewDuePath(configPath) {
4210
+ const key = `${realpathOr(path6.dirname(configPath))}\x00${path6.basename(configPath)}`;
4211
+ return path6.join(reviewDueDir(), `${crypto3.createHash("sha256").update(key).digest("hex").slice(0, 16)}.json`);
4212
+ }
4213
+ function writeReviewDue(configPath, dueAt) {
4214
+ const filePath = reviewDuePath(configPath);
4215
+ fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
4216
+ const temporary = `${filePath}.${crypto3.randomUUID()}.tmp`;
4217
+ try {
4218
+ fs6.writeFileSync(temporary, `${JSON.stringify({ configPath, dueAt: new Date(dueAt).toISOString() })}
4219
+ `, "utf8");
4220
+ fs6.renameSync(temporary, filePath);
4221
+ } finally {
4222
+ fs6.rmSync(temporary, { force: true });
4223
+ }
4224
+ }
4225
+ function clearReviewDue(configPath) {
4226
+ fs6.rmSync(reviewDuePath(configPath), { force: true });
4227
+ }
4228
+ function readReviewDue(configPath) {
4229
+ try {
4230
+ const { dueAt } = JSON.parse(fs6.readFileSync(reviewDuePath(configPath), "utf8"));
4231
+ const time = typeof dueAt === "string" ? Date.parse(dueAt) : Number.NaN;
4232
+ return Number.isFinite(time) ? time : undefined;
4233
+ } catch {
4234
+ return;
4235
+ }
4236
+ }
4203
4237
  var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES, SYNC_LOCK_WAIT_MS;
4204
4238
  var init_state = __esm(() => {
4205
4239
  init_util();
@@ -4924,8 +4958,9 @@ function syncProject(root, config, options) {
4924
4958
  }
4925
4959
  if (!options.remote)
4926
4960
  return {};
4927
- if (reviewRequired(config))
4928
- return publishReviewed(root, sidecarPath, config, inbox, siblings, stage);
4961
+ if (reviewRequired(config)) {
4962
+ return publishReviewed(root, sidecarPath, config, inbox, siblings, stage, options.reviewFloor ?? false);
4963
+ }
4929
4964
  stage("push-inbox");
4930
4965
  syncBranchBeforePush(sidecarPath, inbox, config);
4931
4966
  pushBranch(sidecarPath, inbox);
@@ -5025,7 +5060,7 @@ function publicTip(repo, branch) {
5025
5060
  function isPublic(repo, commit) {
5026
5061
  return Boolean(git(repo, ["for-each-ref", "--contains", commit, "--format=%(refname)", PUBLIC_REFS]).stdout.trim());
5027
5062
  }
5028
- function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
5063
+ function publishReviewed(root, sidecarPath, config, inbox, siblings, stage, reviewFloor) {
5029
5064
  stage("fetch");
5030
5065
  withPublicGit(sidecarPath, () => {
5031
5066
  fetchPublic(sidecarPath);
@@ -5043,12 +5078,25 @@ function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
5043
5078
  stage("settle-remote");
5044
5079
  settleCheckouts(sidecarPath, config, inbox, siblings);
5045
5080
  stage("review");
5046
- const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config));
5047
- if (gate?.launch)
5081
+ const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config, reviewFloor));
5082
+ if (!gate)
5083
+ return {};
5084
+ if ("due" in gate)
5085
+ return { reviewDue: gate.due };
5086
+ if (gate.launch)
5048
5087
  launchReviewer(gate.review);
5049
- return gate ? { held: { review: gate.review, attention: reviewAttention(gate.review) } } : {};
5088
+ return { held: { review: gate.review, attention: reviewAttention(gate.review) } };
5050
5089
  }
5051
- function gateUnpublished(root, sidecarPath, config) {
5090
+ function gateUnpublished(root, sidecarPath, config, floor) {
5091
+ const configPath = peerConfigPath(root, config.peer);
5092
+ const result = gateReview(root, sidecarPath, config, floor);
5093
+ if (result && "due" in result)
5094
+ writeReviewDue(configPath, result.due);
5095
+ else
5096
+ clearReviewDue(configPath);
5097
+ return result;
5098
+ }
5099
+ function gateReview(root, sidecarPath, config, floor) {
5052
5100
  const head = git(sidecarPath, ["rev-parse", `refs/heads/${config.branch}`]).stdout.trim();
5053
5101
  const headTree = treeOf(sidecarPath, head);
5054
5102
  const publicMain = publicTip(sidecarPath, config.branch);
@@ -5070,10 +5118,25 @@ function gateUnpublished(root, sidecarPath, config) {
5070
5118
  return { review: open, launch: false };
5071
5119
  markSuperseded(open);
5072
5120
  }
5121
+ if (floor) {
5122
+ const due = reviewFloorEndsAt(sidecarPath, config);
5123
+ if (due > Date.now()) {
5124
+ console.log(`the next review opens in ${Math.ceil((due - Date.now()) / 1000)}s; reviews open at most once per debounce`);
5125
+ return { due };
5126
+ }
5127
+ }
5073
5128
  const review = createReview({ root, config, sidecarPath, base, head });
5074
5129
  console.log(`opened review ${paint("brand", review.record.id)} for ${review.record.files.length} changed file(s)`);
5075
5130
  return { review, launch: hasReviewer };
5076
5131
  }
5132
+ function reviewFloorEndsAt(sidecarPath, config) {
5133
+ const previous = listReviews(sidecarPath)[0];
5134
+ if (!previous)
5135
+ return 0;
5136
+ const { createdAt, launchedAt } = previous.record;
5137
+ const started = Math.max(Date.parse(createdAt), launchedAt ? Date.parse(launchedAt) : 0);
5138
+ return started + (config.debounce ?? DEFAULT_DEBOUNCE_SECONDS) * 1000;
5139
+ }
5077
5140
  function publishApprovedReview(sidecarPath, config, inbox, review) {
5078
5141
  const { record } = review;
5079
5142
  const target = record.publishing ?? { inbox, commit: cutPublishCommit(sidecarPath, config, inbox, record) };
@@ -5333,7 +5396,6 @@ function readFleetHealth(sidecarPath) {
5333
5396
  return entries.sort((left, right) => rank[left.state] - rank[right.state] || left.record.machine.localeCompare(right.record.machine) || left.branch.localeCompare(right.branch));
5334
5397
  }
5335
5398
  function mergeInboxBranches(sidecarPath, config, options) {
5336
- ensureClean(sidecarPath);
5337
5399
  ensureCommitIdentity(sidecarPath);
5338
5400
  if (options.remote)
5339
5401
  fetch(sidecarPath, false);
@@ -5353,9 +5415,11 @@ function mergeInboxBranches(sidecarPath, config, options) {
5353
5415
  }
5354
5416
  }
5355
5417
  if (!hasAnyCommit(sidecarPath)) {
5418
+ ensureClean(sidecarPath);
5356
5419
  return mergeInboxBranchesAt(sidecarPath, config, options);
5357
5420
  }
5358
5421
  if (git(sidecarPath, ["branch", "--show-current"]).stdout.trim() === config.branch) {
5422
+ ensureClean(sidecarPath);
5359
5423
  ensureInboxBranch(sidecarPath, config, expandInbox(config, sidecarPath));
5360
5424
  }
5361
5425
  return withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => mergeInboxBranchesAt(worktree, config, options));
@@ -5915,7 +5979,7 @@ function syncBranchBeforePush(repo, branch, config) {
5915
5979
  if (isAncestor(repo, remoteBranch, "HEAD"))
5916
5980
  return;
5917
5981
  if (isDirty(repo)) {
5918
- throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes`);
5982
+ throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes`, { reason: "dirty-checkout" });
5919
5983
  }
5920
5984
  if (isAncestor(repo, "HEAD", remoteBranch)) {
5921
5985
  git(repo, ["merge", "--ff-only", remoteBranch]);
@@ -5926,12 +5990,19 @@ function syncBranchBeforePush(repo, branch, config) {
5926
5990
  function refreshInboxFromMain(repo, config, inbox) {
5927
5991
  if (!branchExists(repo, inbox) || !branchExists(repo, config.branch))
5928
5992
  return;
5929
- ensureClean(repo);
5993
+ if (isDirty(repo)) {
5994
+ logSidecarEvent("settle-deferred", { sidecarPath: repo });
5995
+ return;
5996
+ }
5930
5997
  git(repo, ["switch", inbox]);
5931
5998
  const result = git(repo, ["merge", "--ff-only", config.branch], { check: false });
5932
- if (result.status !== 0) {
5933
- throw new SidecarError(result.stderr.trim() || `could not fast-forward ${inbox} to ${config.branch}`);
5999
+ if (result.status === 0)
6000
+ return;
6001
+ if (isDirty(repo)) {
6002
+ logSidecarEvent("settle-deferred", { sidecarPath: repo });
6003
+ return;
5934
6004
  }
6005
+ throw new SidecarError(result.stderr.trim() || `could not fast-forward ${inbox} to ${config.branch}`);
5935
6006
  }
5936
6007
  function pushBranch(repo, branch) {
5937
6008
  git(repo, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${branch}`]);
@@ -8229,6 +8300,7 @@ __export(exports_daemon, {
8229
8300
  selectWatchTargets: () => selectWatchTargets,
8230
8301
  scheduleFor: () => scheduleFor,
8231
8302
  runDaemonLoop: () => runDaemonLoop,
8303
+ reviewDueAt: () => reviewDueAt,
8232
8304
  refreshWatchers: () => refreshWatchers,
8233
8305
  compileGitignoreMatcher: () => compileGitignoreMatcher,
8234
8306
  checkAndInstallUpdate: () => checkAndInstallUpdate,
@@ -8299,6 +8371,7 @@ async function runDaemonLoop(options) {
8299
8371
  await acquireDaemonPid();
8300
8372
  installShutdownHandlers();
8301
8373
  await watchRegistry(state);
8374
+ await watchReviewDue(state);
8302
8375
  const bootVersion = packageVersion();
8303
8376
  while (true) {
8304
8377
  maybeAdoptNewerInstall(state, bootVersion);
@@ -8411,10 +8484,11 @@ async function syncInstance(state, key, trigger, options = {}) {
8411
8484
  state.syncingFamilies.add(family);
8412
8485
  if (!options.localOnly) {
8413
8486
  state.lastRemoteSyncAt.set(key, Date.now());
8414
- clearTimeout(state.remoteTimers.get(key));
8487
+ clearTimeout(state.remoteTimers.get(key)?.timer);
8415
8488
  state.remoteTimers.delete(key);
8416
8489
  }
8417
8490
  let succeeded = false;
8491
+ let dirtyCheckout = false;
8418
8492
  try {
8419
8493
  const localCli = localSidecarCliPath(root);
8420
8494
  const cli = localCli ?? currentCliPath();
@@ -8431,19 +8505,27 @@ async function syncInstance(state, key, trigger, options = {}) {
8431
8505
  },
8432
8506
  timeoutMs: SYNC_TIMEOUT_MS
8433
8507
  });
8508
+ dirtyCheckout = !result.timedOut && result.status === DIRTY_CHECKOUT_EXIT;
8434
8509
  if (result.status === 0) {
8435
- state.failures.delete(key);
8436
- state.skipUntilCycle.delete(key);
8510
+ if (!options.localOnly) {
8511
+ state.failures.delete(key);
8512
+ state.skipUntilCycle.delete(key);
8513
+ }
8437
8514
  logSidecarEvent("daemon-sync", { ...peerFields(key), trigger, local: Boolean(localCli) });
8438
8515
  succeeded = true;
8439
8516
  } else {
8440
- const failures = (state.failures.get(key) ?? 0) + 1;
8441
- state.failures.set(key, failures);
8442
- state.skipUntilCycle.set(key, state.cycleCount + Math.min(2 ** (failures - 1), MAX_BACKOFF_CYCLES));
8517
+ if (!dirtyCheckout) {
8518
+ const failures = (state.failures.get(key) ?? 0) + 1;
8519
+ state.failures.set(key, failures);
8520
+ state.skipUntilCycle.set(key, state.cycleCount + Math.min(2 ** (failures - 1), MAX_BACKOFF_CYCLES));
8521
+ }
8522
+ if (!options.localOnly)
8523
+ state.lastRemoteSyncAt.set(key, Date.now());
8443
8524
  logSidecarEvent("failure", {
8444
8525
  command: "daemon",
8445
8526
  ...peerFields(key),
8446
8527
  trigger,
8528
+ reason: result.timedOut ? "timeout" : dirtyCheckout ? "dirty-checkout" : `exit-${result.status}`,
8447
8529
  message: result.timedOut ? "sync timed out" : !outputIsPrivate(key) && result.output.trim().slice(-500) || `sync exited ${result.status}`
8448
8530
  });
8449
8531
  }
@@ -8453,12 +8535,24 @@ async function syncInstance(state, key, trigger, options = {}) {
8453
8535
  if (options.localOnly)
8454
8536
  armRemoteSync(state, key);
8455
8537
  }
8456
- if (succeeded)
8538
+ if (succeeded) {
8539
+ if (!options.localOnly)
8540
+ armReviewDue(state, key, "after-sync");
8457
8541
  await followUpTrailingSync(state, key);
8458
- else
8459
- state.trailingPending.delete(key);
8542
+ } else {
8543
+ followUpFailedSync(state, key, { dirtyCheckout, remote: !options.localOnly });
8544
+ }
8460
8545
  return succeeded;
8461
8546
  }
8547
+ function followUpFailedSync(state, key, failure) {
8548
+ if (failure.dirtyCheckout) {
8549
+ state.trailingPending.add(key);
8550
+ if (failure.remote)
8551
+ armRemoteSync(state, key);
8552
+ }
8553
+ if (state.trailingPending.has(key) && !state.pendingTimers.has(key))
8554
+ openTrailingWindow(state, key, SETTLE_WINDOW_MS);
8555
+ }
8462
8556
  async function followUpTrailingSync(state, key) {
8463
8557
  if (!state.trailingPending.delete(key))
8464
8558
  return;
@@ -8474,7 +8568,9 @@ async function checkoutIsDirty(key) {
8474
8568
  if (!sidecarPath || !fs14.existsSync(sidecarPath))
8475
8569
  return false;
8476
8570
  const result = await runChild("git", ["-C", sidecarPath, "status", "--porcelain"], { timeoutMs: 30000 });
8477
- return result.status === 0 && Boolean(result.stdout.trim());
8571
+ if (result.status !== 0)
8572
+ return true;
8573
+ return Boolean(result.stdout.trim());
8478
8574
  }
8479
8575
  function localSidecarCliPath(root) {
8480
8576
  if (!projectDependsOnSidecar(root))
@@ -8602,14 +8698,35 @@ async function beginWatchSync(state, key) {
8602
8698
  state.trailingPending.add(key);
8603
8699
  }
8604
8700
  function armRemoteSync(state, key) {
8605
- if (state.remoteTimers.has(key))
8606
- return;
8607
8701
  const elapsed = Date.now() - (state.lastRemoteSyncAt.get(key) ?? 0);
8702
+ armRemoteAt(state, key, Date.now() + Math.max(SETTLE_WINDOW_MS, scheduleFor(key, state.options).debounceSeconds * 1000 - elapsed), "remote-due");
8703
+ }
8704
+ function armRemoteAt(state, key, at, trigger) {
8705
+ const booked = state.remoteTimers.get(key);
8706
+ if (booked && booked.at <= at)
8707
+ return;
8708
+ clearTimeout(booked?.timer);
8608
8709
  const timer = setTimeout(() => {
8609
8710
  state.remoteTimers.delete(key);
8610
- syncInstance(state, key, "remote-due");
8611
- }, Math.max(SETTLE_WINDOW_MS, scheduleFor(key, state.options).debounceSeconds * 1000 - elapsed));
8612
- state.remoteTimers.set(key, timer);
8711
+ syncInstance(state, key, trigger);
8712
+ }, Math.max(0, at - Date.now()));
8713
+ state.remoteTimers.set(key, { timer, at });
8714
+ }
8715
+ function armReviewDue(state, key, when) {
8716
+ const at = reviewDueAt(readReviewDue(key), when, Date.now(), scheduleFor(key, state.options).debounceSeconds * 1000);
8717
+ if (at !== undefined)
8718
+ armRemoteAt(state, key, at, "review-due");
8719
+ }
8720
+ function reviewDueAt(due, when, now, debounceMs) {
8721
+ if (due === undefined)
8722
+ return;
8723
+ if (due > now)
8724
+ return due;
8725
+ if (when === "start")
8726
+ return now;
8727
+ if (when === "after-sync")
8728
+ return now + Math.max(SETTLE_WINDOW_MS, debounceMs);
8729
+ return;
8613
8730
  }
8614
8731
  function remoteIsDue(state, key) {
8615
8732
  const last = state.lastRemoteSyncAt.get(key) ?? 0;
@@ -8653,6 +8770,34 @@ async function watchRegistry(state) {
8653
8770
  });
8654
8771
  }
8655
8772
  }
8773
+ async function watchReviewDue(state) {
8774
+ const scan = (when) => {
8775
+ for (const instance of readInstances())
8776
+ armReviewDue(state, instance.configPath, when);
8777
+ };
8778
+ scan("start");
8779
+ const chokidar = await loadChokidar();
8780
+ if (!chokidar)
8781
+ return;
8782
+ try {
8783
+ fs14.mkdirSync(reviewDueDir(), { recursive: true });
8784
+ const watcher = chokidar.watch(reviewDueDir(), { ignoreInitial: true, depth: 0 });
8785
+ let timer;
8786
+ watcher.on("all", () => {
8787
+ if (timer)
8788
+ return;
8789
+ timer = setTimeout(() => {
8790
+ timer = undefined;
8791
+ scan("change");
8792
+ }, 500);
8793
+ });
8794
+ } catch (error) {
8795
+ logSidecarEvent("failure", {
8796
+ command: "daemon",
8797
+ message: `could not watch review due times: ${error instanceof Error ? error.message : String(error)}`
8798
+ });
8799
+ }
8800
+ }
8656
8801
  function compileGitignoreMatcher(lines) {
8657
8802
  const rules = import_ignore.default({ ignorecase: false }).add(lines);
8658
8803
  return (relativePath) => {
@@ -8978,7 +9123,7 @@ async function cmdDaemonRun(args) {
8978
9123
  if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
8979
9124
  throw new SidecarError("--interval must be > 0");
8980
9125
  }
8981
- const debounceSeconds = Number(getValue(parsed, "--debounce", "60"));
9126
+ const debounceSeconds = Number(getValue(parsed, "--debounce", String(DEFAULT_DEBOUNCE_SECONDS)));
8982
9127
  if (!Number.isFinite(debounceSeconds) || debounceSeconds < 0) {
8983
9128
  throw new SidecarError("--debounce must be >= 0");
8984
9129
  }
@@ -9102,7 +9247,8 @@ function cmdSync(args) {
9102
9247
  lock: soft ? "skip" : "throw",
9103
9248
  remote: !parsed.flags.has("--local") && process.env[LOCAL_SYNC_ENV] !== "1",
9104
9249
  snapshot: !parsed.flags.has("--no-snapshot"),
9105
- message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined
9250
+ message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined,
9251
+ reviewFloor: soft
9106
9252
  };
9107
9253
  for (const peer of peers) {
9108
9254
  announcePeer(peer, peers);
@@ -9113,12 +9259,13 @@ function cmdSync(args) {
9113
9259
  } catch (error) {
9114
9260
  if (peers.length === 1)
9115
9261
  throw error;
9116
- failed.push(peer.name);
9262
+ failed.push({ name: peer.name, error });
9117
9263
  console.error(`sidecar: ${peer.name}: ${error instanceof Error ? error.message : String(error)}`);
9118
9264
  }
9119
9265
  }
9120
9266
  if (failed.length) {
9121
- throw new SidecarError(`${failed.length} of ${peers.length} peers failed to sync: ${failed.join(", ")}`);
9267
+ const dirty = failed.every(({ error }) => error instanceof SidecarError && error.reason === "dirty-checkout");
9268
+ throw new SidecarError(`${failed.length} of ${peers.length} peers failed to sync: ${failed.map(({ name }) => name).join(", ")}`, dirty ? { reason: "dirty-checkout" } : {});
9122
9269
  }
9123
9270
  return 0;
9124
9271
  }
@@ -9135,6 +9282,7 @@ function syncPeer(peer, options) {
9135
9282
  snapshot: options.snapshot,
9136
9283
  remote,
9137
9284
  message: options.message,
9285
+ reviewFloor: options.reviewFloor,
9138
9286
  onStage: (name) => {
9139
9287
  stage = name;
9140
9288
  }
@@ -9142,6 +9290,8 @@ function syncPeer(peer, options) {
9142
9290
  registerCurrentInstance(root, config, remote ? { event: "sync", lastSyncAt: nowIso() } : { event: "sync-local" });
9143
9291
  });
9144
9292
  } catch (error) {
9293
+ if (error instanceof SidecarError)
9294
+ error.stage ??= stage;
9145
9295
  if (remote)
9146
9296
  reportSyncHealth(root, config, {
9147
9297
  status: "failed",
@@ -9156,8 +9306,9 @@ function syncPeer(peer, options) {
9156
9306
  const { id } = held.review.record;
9157
9307
  console.log(held.attention ? `sidecar: publishing is held — review ${id} needs attention: ${held.attention}; \`sidecar review show ${id}\`` : `sidecar: publishing is held for review ${id}; \`sidecar review show ${id}\``);
9158
9308
  }
9159
- if (remote)
9160
- reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : { status: "ok" });
9309
+ if (remote) {
9310
+ reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : outcome.reviewDue ? { status: "ok", review: "pending" } : { status: "ok" });
9311
+ }
9161
9312
  if (!soft) {
9162
9313
  const sidecarPath = resolveSidecarPath(root, config);
9163
9314
  if (checkoutIsUnlinkedFromFamily(root, config, sidecarPath)) {
@@ -9425,7 +9576,20 @@ function decideCommand(verdict, args) {
9425
9576
  return 0;
9426
9577
  }
9427
9578
  const peer = loadPeer(selected.record.root, selected.record.peer);
9428
- syncPeer(peer, { lock: "wait", remote: true, snapshot: true });
9579
+ try {
9580
+ syncPeer(peer, { lock: "wait", remote: true, snapshot: true, reviewFloor: true });
9581
+ } catch (error) {
9582
+ if (readReview(id)?.state !== "published") {
9583
+ console.error(`sidecar: review ${id} is approved, but this sync could not confirm it published; the next sync finishes publishing it`);
9584
+ throw error;
9585
+ }
9586
+ logSidecarEvent("review-followup-failure", {
9587
+ id,
9588
+ ...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
9589
+ ...error instanceof SidecarError && error.stage ? { stage: error.stage } : {}
9590
+ });
9591
+ console.error(`sidecar: published review ${id}; the sync after it stopped early and the next sync finishes it: ${error instanceof Error ? error.message : String(error)}`);
9592
+ }
9429
9593
  return 0;
9430
9594
  }
9431
9595
  function retryCommand(args) {
@@ -9680,12 +9844,14 @@ async function main(argv = process.argv.slice(2)) {
9680
9844
  if (command !== "redact" && command !== "deinit" && shouldUseGlobalRegistry()) {
9681
9845
  logSidecarEvent("failure", {
9682
9846
  command,
9847
+ ...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
9848
+ ...error instanceof SidecarError && error.stage ? { stage: error.stage } : {},
9683
9849
  ...privateFailures() ? {} : { message: error instanceof Error ? error.message : String(error) }
9684
9850
  });
9685
9851
  }
9686
9852
  if (error instanceof SidecarError) {
9687
9853
  console.error(`${paint("bad", "sidecar:", colorLevel(process.stderr))} ${error.message}`);
9688
- return 1;
9854
+ return error.reason === "dirty-checkout" ? DIRTY_CHECKOUT_EXIT : 1;
9689
9855
  }
9690
9856
  if (error instanceof Error && error.name === "AbortError") {
9691
9857
  console.error("sidecar: stopped");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidecarsync",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "A Git-backed sidecar inbox for repo-local development metadata.",
5
5
  "license": "MIT",
6
6
  "repository": {