@indigoai-us/hq-cloud 6.13.2 → 6.13.3

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.
@@ -203,6 +203,98 @@ describe("reindex", () => {
203
203
  ).toBe("../../../core/skills/demo/SKILL.md");
204
204
  });
205
205
 
206
+ // ── Cleanup pass C: orphan personal-overlay mirror symlinks ──────────────
207
+ // The mirror creates core/<type>/<entry> -> ../../personal/<type>/<entry>.
208
+ // When the personal source is later deleted/renamed, the next reindex must
209
+ // prune the now-dangling core symlink — the gap that used to leave a retired
210
+ // personal policy pointer surfacing in core/policies/ until hand-deleted.
211
+
212
+ it("prunes an orphan personal-overlay mirror symlink when the source is deleted", () => {
213
+ fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
214
+ fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
215
+ reindex({ repoRoot: root });
216
+ const link = path.join(root, "core/policies/myrule.md");
217
+ expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
218
+
219
+ // Retire the personal source, then reindex again.
220
+ fs.rmSync(path.join(root, "personal/policies/myrule.md"));
221
+ expect(reindex({ repoRoot: root }).status).toBe(0);
222
+
223
+ // The dangling mirror symlink is gone — no entry at all (lstat throws).
224
+ expect(fs.existsSync(link)).toBe(false);
225
+ expect(() => fs.lstatSync(link)).toThrow();
226
+ });
227
+
228
+ it("prunes mirror symlinks when the ENTIRE personal/<type>/ directory is removed", () => {
229
+ fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
230
+ fs.writeFileSync(path.join(root, "personal/policies/a.md"), "a\n");
231
+ fs.writeFileSync(path.join(root, "personal/policies/b.md"), "b\n");
232
+ reindex({ repoRoot: root });
233
+ expect(fs.lstatSync(path.join(root, "core/policies/a.md")).isSymbolicLink()).toBe(true);
234
+ expect(fs.lstatSync(path.join(root, "core/policies/b.md")).isSymbolicLink()).toBe(true);
235
+
236
+ // Nuke the whole personal/policies dir (the creation loop's isDir guard would
237
+ // skip the type entirely — the prune must still run and clean both links).
238
+ fs.rmSync(path.join(root, "personal/policies"), { recursive: true, force: true });
239
+ expect(reindex({ repoRoot: root }).status).toBe(0);
240
+
241
+ expect(fs.existsSync(path.join(root, "core/policies/a.md"))).toBe(false);
242
+ expect(fs.existsSync(path.join(root, "core/policies/b.md"))).toBe(false);
243
+ });
244
+
245
+ it("keeps a LIVE personal-overlay mirror symlink (only orphans are pruned)", () => {
246
+ fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
247
+ fs.writeFileSync(path.join(root, "personal/policies/keep.md"), "keep\n");
248
+ fs.writeFileSync(path.join(root, "personal/policies/drop.md"), "drop\n");
249
+ reindex({ repoRoot: root });
250
+
251
+ // Retire only one source; the other stays live.
252
+ fs.rmSync(path.join(root, "personal/policies/drop.md"));
253
+ reindex({ repoRoot: root });
254
+
255
+ const keep = path.join(root, "core/policies/keep.md");
256
+ expect(fs.lstatSync(keep).isSymbolicLink()).toBe(true);
257
+ expect(fs.readlinkSync(keep)).toBe("../../personal/policies/keep.md");
258
+ expect(fs.readFileSync(keep, "utf-8")).toBe("keep\n");
259
+ expect(fs.existsSync(path.join(root, "core/policies/drop.md"))).toBe(false);
260
+ });
261
+
262
+ it("prunes an orphan personal-overlay mirror DIRECTORY symlink (DEV-1767 shape)", () => {
263
+ // personal/knowledge/<kb>/ mirrors as a directory symlink; deleting the kb
264
+ // must prune the dangling directory symlink too (rmSync on a symlink, not
265
+ // recursive into a resolved dir).
266
+ fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
267
+ fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
268
+ reindex({ repoRoot: root });
269
+ const link = path.join(root, "core/knowledge/my-kb");
270
+ expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
271
+
272
+ fs.rmSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true, force: true });
273
+ expect(reindex({ repoRoot: root }).status).toBe(0);
274
+ expect(fs.existsSync(link)).toBe(false);
275
+ });
276
+
277
+ it("leaves a real core file and a foreign symlink in core/<type>/ untouched (cross-check invariant)", () => {
278
+ fs.mkdirSync(path.join(root, "core/policies"), { recursive: true });
279
+ // (a) a real release-shipped core file — not a symlink, never ours.
280
+ fs.writeFileSync(path.join(root, "core/policies/shipped.md"), "shipped\n");
281
+ // (b) a human-authored symlink in core/policies pointing SOMEWHERE ELSE
282
+ // (not the managed ../../personal/policies/<name> shape) — even though
283
+ // it is dangling, its target doesn't match, so it must survive.
284
+ fs.symlinkSync("../../somewhere/else.md", path.join(root, "core/policies/foreign.md"));
285
+ // (c) a symlink whose target's basename disagrees with its own name — the
286
+ // exact cross-check catches this: name 'mismatch.md' but target points
287
+ // at personal/policies/other.md → NOT managed, left alone.
288
+ fs.symlinkSync("../../personal/policies/other.md", path.join(root, "core/policies/mismatch.md"));
289
+
290
+ expect(reindex({ repoRoot: root }).status).toBe(0);
291
+
292
+ expect(fs.lstatSync(path.join(root, "core/policies/shipped.md")).isSymbolicLink()).toBe(false);
293
+ expect(fs.existsSync(path.join(root, "core/policies/shipped.md"))).toBe(true);
294
+ expect(fs.lstatSync(path.join(root, "core/policies/foreign.md")).isSymbolicLink()).toBe(true);
295
+ expect(fs.lstatSync(path.join(root, "core/policies/mismatch.md")).isSymbolicLink()).toBe(true);
296
+ });
297
+
206
298
  it("skips `_`-prefixed and dotfile skill folders", () => {
207
299
  writeSkill("core/skills/_template");
208
300
  fs.mkdirSync(path.join(root, "core/skills/.hidden"), { recursive: true });
@@ -487,6 +487,41 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
487
487
  }
488
488
  }
489
489
 
490
+ // --- Cleanup pass C: drop orphan personal-overlay mirror symlinks ----------
491
+ // The mirror above creates core/<type>/<entry> -> ../../personal/<type>/<entry>
492
+ // for each LIVE personal entry, but historically never removed that symlink
493
+ // when its personal source was later deleted or renamed — leaving a dangling
494
+ // link in core/<type>/ (e.g. a retired personal policy that still surfaced via
495
+ // the policy-trigger hook until a human hand-deleted it). This pass prunes it.
496
+ //
497
+ // Ownership discriminator (policy wrapper-sync-orphan-cleanup-cross-check): a
498
+ // core/<type>/<entry> is "managed by this mirror" ONLY when it is a symlink
499
+ // whose target is EXACTLY `../../personal/<type>/<entry>` — i.e. the target
500
+ // encodes the same <type> and <entry> as the link's own location and name.
501
+ // That exact cross-check means a real release-shipped core file (not a
502
+ // symlink), or a human-authored symlink pointing anywhere else, never matches
503
+ // and is left untouched. A managed link is pruned IFF its target no longer
504
+ // resolves (existsFollow=false) — which, given the exact-target shape, is
505
+ // precisely "the personal source was removed". This runs independently of the
506
+ // creation loop above (note: no `isDir(personalDir)` guard) so that deleting
507
+ // the ENTIRE personal/<type>/ directory still prunes every mirror it produced.
508
+ for (const type of ["knowledge", "policies", "workers", "settings"]) {
509
+ const coreDir = path.join(root, "core", type);
510
+ for (const entry of globEntries(coreDir)) {
511
+ const linkPath = path.join(coreDir, entry);
512
+ const lst = lstatOrNull(linkPath);
513
+ if (!lst || !lst.isSymbolicLink()) continue; // real file/dir → not ours
514
+ if (readlinkOrNull(linkPath) !== `../../personal/${type}/${entry}`) continue; // not a managed mirror link
515
+ if (existsFollow(linkPath)) continue; // live: personal source still present
516
+ // Managed mirror symlink whose personal source is gone → prune.
517
+ try {
518
+ fs.rmSync(linkPath);
519
+ } catch {
520
+ /* best-effort */
521
+ }
522
+ }
523
+ }
524
+
490
525
  // --- Workers registry regeneration ----------------------------------------
491
526
  // Source of truth: each worker.yaml. The registry is a derived index — the
492
527
  // generator (when present in the operated-on tree) keeps it in sync.
@@ -3141,6 +3141,111 @@ describe("sync", () => {
3141
3141
  expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
3142
3142
  });
3143
3143
 
3144
+ it("stale personal-overlay marker vs release-shipped core dir: skips cleanly, no 'rm -rf' advice (feedback_ce9aeede)", async () => {
3145
+ // Reporter feedback_ce9aeede: a stale personal-overlay symlink marker in
3146
+ // the vault at core/knowledge/public/agent-browser collided with the REAL,
3147
+ // release-shipped core directory of the same name (the overlay mirror once
3148
+ // symlinked it into core/, pushed the marker, then a release shipped a real
3149
+ // dir there). Pre-fix, the pull planner hit the generic dir-vs-object
3150
+ // collision branch and emitted, on EVERY sync, a "manual reconciliation
3151
+ // required (rm -rf the local directory to pull)" warning — advice that here
3152
+ // would DESTROY release-shipped core content to pull an inert marker. The
3153
+ // reporter had to hand-delete the vault object. Fix: recognize the
3154
+ // overlay-mirror core key, keep the local directory, and skip quietly.
3155
+ const markerKey = "core/knowledge/public/agent-browser";
3156
+ const localDir = path.join(
3157
+ tmpDir,
3158
+ "core",
3159
+ "knowledge",
3160
+ "public",
3161
+ "agent-browser",
3162
+ );
3163
+ fs.mkdirSync(localDir, { recursive: true });
3164
+ fs.writeFileSync(
3165
+ path.join(localDir, "INDEX.md"),
3166
+ "release-shipped core content",
3167
+ );
3168
+
3169
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3170
+ { key: markerKey, size: 40, lastModified: new Date(), etag: '"stale-marker"' },
3171
+ ]);
3172
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3173
+
3174
+ let crashed = false;
3175
+ let result;
3176
+ try {
3177
+ result = await sync({
3178
+ company: "acme",
3179
+ vaultConfig: mockConfig,
3180
+ hqRoot: tmpDir,
3181
+ // personalMode → companyRoot === hqRoot, so a `core/...` key resolves
3182
+ // under the HQ root exactly as the reporter's personal-vault pull did.
3183
+ personalMode: true,
3184
+ });
3185
+ } catch {
3186
+ crashed = true;
3187
+ }
3188
+ const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
3189
+ errSpy.mockRestore();
3190
+
3191
+ expect(crashed).toBe(false);
3192
+ expect(result).toBeDefined();
3193
+ // Marker ignored, nothing downloaded, release-shipped dir intact.
3194
+ expect(result!.filesDownloaded).toBe(0);
3195
+ expect(result!.filesSkipped).toBe(1);
3196
+ expect(fs.readFileSync(path.join(localDir, "INDEX.md"), "utf-8")).toBe(
3197
+ "release-shipped core content",
3198
+ );
3199
+ // The dangerous, recurring reconciliation advice is GONE...
3200
+ expect(errOutput).not.toContain("rm -rf");
3201
+ expect(errOutput).not.toContain("manual");
3202
+ // ...replaced by a calm, no-action-needed note naming the stale marker.
3203
+ expect(errOutput).toContain("stale personal-overlay marker");
3204
+ expect(errOutput).toContain(markerKey);
3205
+ });
3206
+
3207
+ it("dir-vs-object collision OUTSIDE the overlay-mirror core namespace still warns to reconcile (no over-broadening)", async () => {
3208
+ // Boundary guard for the feedback_ce9aeede fix: the quiet skip is scoped to
3209
+ // core/{knowledge,policies,workers,settings}/ overlay-mirror keys ONLY. A
3210
+ // dir-vs-object collision at any OTHER key (here `core/docs/...`, a core
3211
+ // subtree that is NOT an overlay-mirror type) is a genuine structural
3212
+ // collision the operator must resolve, so the original warning path — and
3213
+ // its skip-local-only classification — must remain intact.
3214
+ const key = "core/docs/agent-browser";
3215
+ const localDir = path.join(tmpDir, "core", "docs", "agent-browser");
3216
+ fs.mkdirSync(localDir, { recursive: true });
3217
+ fs.writeFileSync(path.join(localDir, "inside.md"), "local content");
3218
+
3219
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3220
+ { key, size: 40, lastModified: new Date(), etag: '"obj"' },
3221
+ ]);
3222
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3223
+
3224
+ let crashed = false;
3225
+ let result;
3226
+ try {
3227
+ result = await sync({
3228
+ company: "acme",
3229
+ vaultConfig: mockConfig,
3230
+ hqRoot: tmpDir,
3231
+ personalMode: true,
3232
+ });
3233
+ } catch {
3234
+ crashed = true;
3235
+ }
3236
+ const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
3237
+ errSpy.mockRestore();
3238
+
3239
+ expect(crashed).toBe(false);
3240
+ expect(result!.filesDownloaded).toBe(0);
3241
+ expect(result!.filesSkipped).toBe(1);
3242
+ expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
3243
+ // Generic collision path preserved: reconciliation warning still emitted,
3244
+ // NOT the overlay-marker note.
3245
+ expect(errOutput).toContain("manual");
3246
+ expect(errOutput).not.toContain("stale personal-overlay marker");
3247
+ });
3248
+
3144
3249
  it("pulls a remote symlink record under an .hqinclude dir-only allowlist pattern", async () => {
3145
3250
  // Codex round-7 P1 follow-up: pre-fix, computePullPlan called
3146
3251
  // shouldSync(localPath) with the default isDir=false. LIST gives
package/src/cli/sync.ts CHANGED
@@ -967,7 +967,8 @@ async function executeConflictExecutor(
967
967
  item.action === "skip-ignored" ||
968
968
  item.action === "skip-personal-mode" ||
969
969
  item.action === "skip-unchanged" ||
970
- item.action === "skip-local-only"
970
+ item.action === "skip-local-only" ||
971
+ item.action === "skip-stale-overlay-marker"
971
972
  ) {
972
973
  counters.filesSkipped++;
973
974
  continue;
@@ -1670,6 +1671,15 @@ type PullPlanItem =
1670
1671
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
1671
1672
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
1672
1673
  | { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
1674
+ // A stale personal-overlay symlink marker in the vault sitting at a key that
1675
+ // is ALSO a real, release-shipped core directory (e.g. an old
1676
+ // `core/knowledge/public/agent-browser` overlay marker after a release
1677
+ // shipped a real directory of the same name). The local release-shipped
1678
+ // directory is authoritative; the marker is inert derived state that must
1679
+ // NEVER overwrite core. Distinct from skip-local-only so the benign,
1680
+ // no-action-needed case is never surfaced with the alarming "rm -rf the
1681
+ // local directory" reconciliation advice.
1682
+ | { action: "skip-stale-overlay-marker"; remoteFile: RemoteFile; localPath: string }
1673
1683
  // Remote keys refused by ephemeral-mirror policy. The push walker has
1674
1684
  // refused to upload these since 5.33.0; the pull walker now refuses to
1675
1685
  // download them so legacy litter in cloud staging drains naturally.
@@ -1757,6 +1767,33 @@ interface PullPlan {
1757
1767
  const PULL_INTENTIONAL_DELETE_MIN_ABS = 10;
1758
1768
  const PULL_INTENTIONAL_DELETE_RATIO = 0.1;
1759
1769
 
1770
+ // The four release-shipped `core/<type>/` namespaces the personal-overlay
1771
+ // mirror (reindex) materializes into. A stale mirror symlink pushed to the
1772
+ // vault lands as a single object at exactly `core/<type>/<...>/<entry>`, and if
1773
+ // a later release ships a REAL directory of the same name, pull sees a
1774
+ // (local real dir) vs (remote single object) collision at that key forever.
1775
+ const OVERLAY_MIRROR_CORE_TYPES = ["knowledge", "policies", "workers", "settings"];
1776
+
1777
+ /**
1778
+ * True when `key` is under a release-shipped personal-overlay-mirror core
1779
+ * namespace (`core/{knowledge,policies,workers,settings}/<entry>/...`).
1780
+ *
1781
+ * A remote LIST entry is always a single S3 object. A REAL release-shipped core
1782
+ * directory only ever syncs as child objects UNDER its key
1783
+ * (`core/knowledge/public/agent-browser/INDEX.md`), never as one object AT the
1784
+ * directory's own key — so a single object at such a key, colliding with a
1785
+ * local real directory, is unambiguously a stale overlay symlink marker, not
1786
+ * live core content. Pure path test — no I/O — safe inside the sync planner.
1787
+ */
1788
+ function isOverlayMirrorCoreKey(key: string): boolean {
1789
+ const parts = key.split("/");
1790
+ return (
1791
+ parts.length >= 3 &&
1792
+ parts[0] === "core" &&
1793
+ OVERLAY_MIRROR_CORE_TYPES.includes(parts[1])
1794
+ );
1795
+ }
1796
+
1760
1797
  function computePullPlan(
1761
1798
  remoteFiles: RemoteFile[],
1762
1799
  journal: SyncJournal,
@@ -1959,6 +1996,29 @@ function computePullPlan(
1959
1996
  // skip and warn so the operator can reconcile manually
1960
1997
  // (delete the local dir, or restructure the remote).
1961
1998
  if (localLstat!.isDirectory() && !isLocalSymlink) {
1999
+ // Stale personal-overlay marker vs. release-shipped core directory:
2000
+ // a single vault object at a `core/<overlay-type>/.../<entry>` key
2001
+ // whose local counterpart is a real directory is an orphaned overlay
2002
+ // symlink marker (reindex mirror artifact) left behind after a release
2003
+ // shipped a real core dir of the same name. The local release-shipped
2004
+ // directory is authoritative and the marker can never be materialized
2005
+ // over it, so keep local and ignore the marker QUIETLY — the generic
2006
+ // "rm -rf the local directory" advice below is actively dangerous here
2007
+ // (it would destroy release-shipped core content to pull inert derived
2008
+ // state), and it recurred on every sync. feedback_ce9aeede.
2009
+ if (isOverlayMirrorCoreKey(remoteFile.key)) {
2010
+ console.error(
2011
+ ` Note: ${remoteFile.key} is a stale personal-overlay marker ` +
2012
+ `shadowing a release-shipped core directory; keeping the local ` +
2013
+ `directory and ignoring the marker (no action needed).`,
2014
+ );
2015
+ items.push({
2016
+ action: "skip-stale-overlay-marker",
2017
+ remoteFile,
2018
+ localPath,
2019
+ });
2020
+ continue;
2021
+ }
1962
2022
  console.error(
1963
2023
  ` Warning: ${remoteFile.key} exists locally as a directory; ` +
1964
2024
  `cloud has a single object at this key. Skipping; manual ` +