@indigoai-us/hq-cli 5.345.172 → 5.345.173

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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.173] — 2026-10-27
6
+ - The sync-core reconcile now counts the conflict and merge actions in a vault's read-only plan as writes: a vault is held read-only while its plan would write more than 100 conflict or merge copies, the count is recorded next to the plan's upload, download and delete counts in `~/.hq/sync-core/rollout.json`, and `hq sync-core status` prints it.
7
+ - A machine can keep one vault off the new sync engine while its company switch is on: `~/.hq/sync-core/skip.json` with `{"vaults": {"<vault uid>": "<reason>"}}` makes the reconcile leave that vault on the current sync (stopping its engine unit if one ran), record the uid and reason under `skipped` in `rollout.json`, and show it in `hq sync-core status`. A malformed file is reported as a warning and skips nothing.
8
+ - The reconcile never downgrades the engine: when `~/.hq/bin/hq-syncd.release` names a release newer than the pinned one and the binary is present, it keeps that binary and runs the engine units with it.
9
+
5
10
  ## [5.345.172] — 2026-10-27
6
11
  - The daemon now moves vaults to the new sync engine by itself, following a per-company switch (`sync.rust-engine-rollout`) instead of a hand-written `syncCoreCanary` entry in `~/.hq/daemon/config.json`. A new `sync-core` service on desktop and self-hosted machines runs `hq daemon service sync-core-reconcile` every 10 minutes: it reads the switch for every company this machine belongs to, downloads the pinned hq-syncd release (v0.1.13) from the public mirror and checks it against the release's SHA256SUMS, installs it at `~/.hq/bin/hq-syncd`, writes one engine home per switched vault under `~/.hq/sync-core/engines/<vault>/`, and records the result in `~/.hq/sync-core/rollout.json`. Each vault's engine first runs read-only alongside the current sync until it has built its index and planned a pass; the reconcile records that plan's upload, download, delete, server-row and local-file counts per vault (`hq sync-core status` shows them). A vault is then promoted to full mode and turned off in the current sync (`menubar.json`) only when the plan would upload or delete at most 100 paths, and the daemon runs its `sync-core:<vault>` unit in full mode only after the current sync has stopped touching that vault. Company vaults stay read-only in this release: the engine's server snapshot is filtered by the caller's visibility while the current sync pulled the whole tree, so a full-mode company engine would upload the difference; promotion waits for an engine release that scopes the local tree the same way. The personal vault moves when at least one of the person's companies is switched on. When a switch turns off, the daemon stops that vault's engine, turns the current sync back on for it, and leaves the engine's index on disk. A switch that cannot be read keeps the vault where it was. `hq sync-core reconcile` and `hq sync-core status` run the same logic by hand (agent boxes run `reconcile --companies-only`). The old `syncCoreCanary` config key is ignored.
7
12
 
@@ -85,11 +85,13 @@ export function runSyncCoreStatus(opts, d = defaultDeps()) {
85
85
  d.out(` ${vault.uid} ${vault.kind}${vault.slug ? ` ${vault.slug}` : ""} ${vault.phase === "full" ? "full" : "read-only"} legacy sync ${legacyOff ? "off" : "still on"} home ${vault.engineHome}`);
86
86
  if (vault.plan) {
87
87
  const p = vault.plan;
88
- d.out(` plan ${p.at}: ${p.remoteRows} server rows, ${p.localFiles} local files; would upload ${p.uploads}, download ${p.downloads}, delete ${p.deletes}`);
88
+ d.out(` plan ${p.at}: ${p.remoteRows} server rows, ${p.localFiles} local files; would upload ${p.uploads}, download ${p.downloads}, delete ${p.deletes}, write ${p.conflicts} conflict or merge copies`);
89
89
  }
90
90
  if (vault.hold)
91
91
  d.out(` held read-only: ${vault.hold}`);
92
92
  }
93
+ for (const skip of state.skipped ?? [])
94
+ d.out(` ${skip.uid} skipped on this machine: ${skip.reason}`);
93
95
  if (state.vaults.length === 0)
94
96
  d.out(" no vault is switched to the new sync engine");
95
97
  }
@@ -34,6 +34,7 @@ export interface SyncCorePlanFigures {
34
34
  uploads: number;
35
35
  downloads: number;
36
36
  deletes: number;
37
+ conflicts: number;
37
38
  remoteRows: number;
38
39
  localFiles: number;
39
40
  }
@@ -104,6 +104,7 @@ export function engineReadOnlyPlan(engineHome, vaultUid) {
104
104
  uploads: count(ops.upload),
105
105
  downloads: count(ops.download),
106
106
  deletes: count(ops.delete_local) + count(ops.delete_remote),
107
+ conflicts: count(ops.conflict) + count(ops.merge),
107
108
  remoteRows: count(record(run.remote)?.rows),
108
109
  localFiles: count(record(run.local)?.files),
109
110
  };
@@ -4,6 +4,7 @@ import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
4
4
  import { readVaultSyncSwitches, writeVaultSyncSwitches } from "../vault-sync-switches.js";
5
5
  import { clearEngineStatus, engineReadOnlyPlan, removeEngineOwnership, syncCoreEngineHome, writeEngineHome, } from "./engine-home.js";
6
6
  import { SYNC_CORE_COMPANY_FULL_RELEASE, SYNC_CORE_PROMOTION_OP_LIMIT, SYNC_CORE_RELEASE, companyFullModeAllowed, installedSyncCoreRelease, syncCoreBinaryPath, } from "./release.js";
7
+ import { readSyncCoreSkips } from "./skip.js";
7
8
  import { readSyncCoreRolloutState, writeSyncCoreRolloutState, } from "./rollout-state.js";
8
9
  function message(error) {
9
10
  return error instanceof Error ? error.message : String(error);
@@ -16,6 +17,9 @@ export function promotionHold(vault, plan, release) {
16
17
  if (plan.deletes > SYNC_CORE_PROMOTION_OP_LIMIT) {
17
18
  return `the read-only plan would delete ${plan.deletes} paths (limit ${SYNC_CORE_PROMOTION_OP_LIMIT})`;
18
19
  }
20
+ if (plan.conflicts > SYNC_CORE_PROMOTION_OP_LIMIT) {
21
+ return `the read-only plan would write ${plan.conflicts} conflict or merge copies (limit ${SYNC_CORE_PROMOTION_OP_LIMIT})`;
22
+ }
19
23
  if (plan.uploads > SYNC_CORE_PROMOTION_OP_LIMIT) {
20
24
  return `the read-only plan would upload ${plan.uploads} files against ${plan.remoteRows} server rows (limit ${SYNC_CORE_PROMOTION_OP_LIMIT})`;
21
25
  }
@@ -77,6 +81,20 @@ export async function reconcileSyncCore(deps) {
77
81
  if (personUid)
78
82
  desired.push({ uid: personUid, kind: "personal" });
79
83
  }
84
+ const skipped = [];
85
+ try {
86
+ const skips = readSyncCoreSkips(deps.home);
87
+ for (let i = desired.length - 1; i >= 0; i -= 1) {
88
+ const skip = skips.find((entry) => entry.uid === desired[i]?.uid);
89
+ if (!skip)
90
+ continue;
91
+ skipped.unshift(skip);
92
+ desired.splice(i, 1);
93
+ }
94
+ }
95
+ catch (error) {
96
+ warnings.push(message(error));
97
+ }
80
98
  let binaryPath = syncCoreBinaryPath(deps.home);
81
99
  let installedRelease = installedSyncCoreRelease(deps.home);
82
100
  if (desired.length > 0) {
@@ -165,6 +183,7 @@ export async function reconcileSyncCore(deps) {
165
183
  release: installedRelease ?? release,
166
184
  binaryPath,
167
185
  vaults,
186
+ ...(skipped.length > 0 ? { skipped } : {}),
168
187
  ...(warnings.length > 0 ? { error: warnings.join("; ") } : {}),
169
188
  };
170
189
  writeSyncCoreRolloutState(deps.home, state);
@@ -189,6 +208,8 @@ export async function reconcileSyncCore(deps) {
189
208
  log(`${uid}: index warm; moving to the new sync engine`);
190
209
  for (const uid of stopped)
191
210
  log(`${uid}: back on the current sync`);
211
+ for (const skip of skipped)
212
+ log(`${skip.uid}: skipped on this machine: ${skip.reason}`);
192
213
  for (const warning of warnings)
193
214
  log(`warning: ${warning}`);
194
215
  return { state, started, promoted, stopped, kept, warnings };
@@ -94,8 +94,10 @@ async function download(fetcher, url) {
94
94
  export async function ensureSyncCoreBinary(opts) {
95
95
  const release = opts.release ?? SYNC_CORE_RELEASE;
96
96
  const binaryPath = syncCoreBinaryPath(opts.home);
97
- if (installedSyncCoreRelease(opts.home) === release)
98
- return { binaryPath, release, installed: false };
97
+ const installedRelease = installedSyncCoreRelease(opts.home);
98
+ if (installedRelease && compareSyncCoreReleases(installedRelease, release) >= 0 && fs.existsSync(binaryPath)) {
99
+ return { binaryPath, release: installedRelease, installed: false };
100
+ }
99
101
  const asset = syncCoreAssetName(release, opts.platform, opts.arch);
100
102
  if (!asset)
101
103
  throw new SyncCoreReleaseError(`the sync engine has no ${release} build for ${opts.platform} on ${opts.arch}`);
@@ -16,6 +16,10 @@ export interface SyncCoreRolloutState {
16
16
  release: string;
17
17
  binaryPath: string;
18
18
  vaults: RolloutVault[];
19
+ skipped?: {
20
+ uid: string;
21
+ reason: string;
22
+ }[];
19
23
  error?: string;
20
24
  }
21
25
  export declare function syncCoreRolloutStatePath(home: string): string;
@@ -10,15 +10,32 @@ function planFigures(value) {
10
10
  const numbers = ["uploads", "downloads", "deletes", "remoteRows", "localFiles"];
11
11
  if (numbers.some((key) => typeof p[key] !== "number" || !Number.isFinite(p[key])))
12
12
  return null;
13
+ if (p.conflicts !== undefined && (typeof p.conflicts !== "number" || !Number.isFinite(p.conflicts)))
14
+ return null;
13
15
  return {
14
16
  at: p.at,
15
17
  uploads: p.uploads,
16
18
  downloads: p.downloads,
17
19
  deletes: p.deletes,
20
+ conflicts: typeof p.conflicts === "number" ? p.conflicts : 0,
18
21
  remoteRows: p.remoteRows,
19
22
  localFiles: p.localFiles,
20
23
  };
21
24
  }
25
+ function skippedEntries(value) {
26
+ if (value === undefined)
27
+ return [];
28
+ if (!Array.isArray(value))
29
+ return null;
30
+ const out = [];
31
+ for (const entry of value) {
32
+ const e = entry && typeof entry === "object" ? entry : undefined;
33
+ if (!e || !isSyncCoreVaultUid(e.uid) || typeof e.reason !== "string")
34
+ return null;
35
+ out.push({ uid: e.uid, reason: e.reason });
36
+ }
37
+ return out;
38
+ }
22
39
  export function syncCoreRolloutStatePath(home) {
23
40
  return path.join(home, ".hq", "sync-core", "rollout.json");
24
41
  }
@@ -75,12 +92,16 @@ export function readSyncCoreRolloutState(home) {
75
92
  const vaults = obj.vaults.map(vault);
76
93
  if (vaults.some((v) => v === null))
77
94
  return null;
95
+ const skipped = skippedEntries(obj.skipped);
96
+ if (skipped === null)
97
+ return null;
78
98
  return {
79
99
  version: 1,
80
100
  readAt: obj.readAt,
81
101
  release: obj.release,
82
102
  binaryPath: obj.binaryPath,
83
103
  vaults: vaults,
104
+ ...(skipped.length > 0 ? { skipped } : {}),
84
105
  ...(typeof obj.error === "string" ? { error: obj.error } : {}),
85
106
  };
86
107
  }
@@ -0,0 +1,7 @@
1
+ export interface SyncCoreSkip {
2
+ uid: string;
3
+ reason: string;
4
+ }
5
+ export declare function syncCoreSkipPath(home: string): string;
6
+ export declare function readSyncCoreSkips(home: string): SyncCoreSkip[];
7
+ //# sourceMappingURL=skip.d.ts.map
@@ -0,0 +1,30 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { isSyncCoreVaultUid } from "./engine-home.js";
4
+ export function syncCoreSkipPath(home) {
5
+ return path.join(home, ".hq", "sync-core", "skip.json");
6
+ }
7
+ export function readSyncCoreSkips(home) {
8
+ let raw;
9
+ try {
10
+ raw = JSON.parse(fs.readFileSync(syncCoreSkipPath(home), "utf8"));
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ return [];
15
+ throw new Error(`${syncCoreSkipPath(home)} could not be read: ${error instanceof Error ? error.message : String(error)}`);
16
+ }
17
+ const vaults = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.vaults : undefined;
18
+ if (!vaults || typeof vaults !== "object" || Array.isArray(vaults)) {
19
+ throw new Error(`${syncCoreSkipPath(home)} must be {"vaults": {"<vault uid>": "<reason>"}}`);
20
+ }
21
+ const skips = [];
22
+ for (const [uid, reason] of Object.entries(vaults)) {
23
+ if (!isSyncCoreVaultUid(uid) || typeof reason !== "string" || reason.trim() === "") {
24
+ throw new Error(`${syncCoreSkipPath(home)} must map vault uids to a non-empty reason (bad entry ${uid})`);
25
+ }
26
+ skips.push({ uid, reason: reason.trim() });
27
+ }
28
+ return skips;
29
+ }
30
+ //# sourceMappingURL=skip.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.172",
3
+ "version": "5.345.173",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.172",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.172",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.172",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.172",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.172",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.172"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.173",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.173",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.173",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.173",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.173",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.173"
123
123
  }
124
124
  }