@indigoai-us/hq-cloud 6.14.18 → 6.14.20

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 (101) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/dist/bin/sync-runner-company.d.ts +7 -0
  3. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-company.js +21 -1
  5. package/dist/bin/sync-runner-company.js.map +1 -1
  6. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-planning.js +8 -16
  8. package/dist/bin/sync-runner-planning.js.map +1 -1
  9. package/dist/bin/sync-runner-planning.test.js +138 -0
  10. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  11. package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
  12. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  13. package/dist/bin/sync-runner-watch-loop.js +201 -25
  14. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  15. package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
  16. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  17. package/dist/bin/sync-runner-watch-routes.js +14 -2
  18. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  19. package/dist/bin/sync-runner.d.ts +17 -2
  20. package/dist/bin/sync-runner.d.ts.map +1 -1
  21. package/dist/bin/sync-runner.js +36 -6
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +550 -3
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/rescue-core.js +27 -1
  26. package/dist/cli/rescue-core.js.map +1 -1
  27. package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
  28. package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
  29. package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
  30. package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
  31. package/dist/cli/share.d.ts +15 -0
  32. package/dist/cli/share.d.ts.map +1 -1
  33. package/dist/cli/share.js +100 -55
  34. package/dist/cli/share.js.map +1 -1
  35. package/dist/cli/share.test.js +119 -6
  36. package/dist/cli/share.test.js.map +1 -1
  37. package/dist/cli/sync.d.ts.map +1 -1
  38. package/dist/cli/sync.js +19 -25
  39. package/dist/cli/sync.js.map +1 -1
  40. package/dist/cli/sync.test.js +121 -0
  41. package/dist/cli/sync.test.js.map +1 -1
  42. package/dist/journal.d.ts +3 -1
  43. package/dist/journal.d.ts.map +1 -1
  44. package/dist/journal.js +13 -2
  45. package/dist/journal.js.map +1 -1
  46. package/dist/journal.test.js +26 -1
  47. package/dist/journal.test.js.map +1 -1
  48. package/dist/personal-vault.d.ts +12 -0
  49. package/dist/personal-vault.d.ts.map +1 -1
  50. package/dist/personal-vault.js +20 -0
  51. package/dist/personal-vault.js.map +1 -1
  52. package/dist/qmd-reindex.d.ts +88 -11
  53. package/dist/qmd-reindex.d.ts.map +1 -1
  54. package/dist/qmd-reindex.js +354 -65
  55. package/dist/qmd-reindex.js.map +1 -1
  56. package/dist/qmd-reindex.test.d.ts +4 -0
  57. package/dist/qmd-reindex.test.d.ts.map +1 -1
  58. package/dist/qmd-reindex.test.js +285 -3
  59. package/dist/qmd-reindex.test.js.map +1 -1
  60. package/dist/s3.d.ts +13 -0
  61. package/dist/s3.d.ts.map +1 -1
  62. package/dist/s3.js +34 -1
  63. package/dist/s3.js.map +1 -1
  64. package/dist/s3.test.js +60 -1
  65. package/dist/s3.test.js.map +1 -1
  66. package/dist/telemetry.d.ts +22 -0
  67. package/dist/telemetry.d.ts.map +1 -1
  68. package/dist/telemetry.js +79 -0
  69. package/dist/telemetry.js.map +1 -1
  70. package/dist/telemetry.test.js +117 -1
  71. package/dist/telemetry.test.js.map +1 -1
  72. package/dist/watcher.d.ts +26 -1
  73. package/dist/watcher.d.ts.map +1 -1
  74. package/dist/watcher.js +75 -12
  75. package/dist/watcher.js.map +1 -1
  76. package/package.json +1 -1
  77. package/src/bin/sync-runner-company.ts +29 -1
  78. package/src/bin/sync-runner-planning.test.ts +153 -0
  79. package/src/bin/sync-runner-planning.ts +10 -17
  80. package/src/bin/sync-runner-watch-loop.ts +322 -32
  81. package/src/bin/sync-runner-watch-routes.ts +14 -1
  82. package/src/bin/sync-runner.test.ts +662 -6
  83. package/src/bin/sync-runner.ts +65 -7
  84. package/src/cli/rescue-core.ts +26 -1
  85. package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
  86. package/src/cli/share.test.ts +150 -6
  87. package/src/cli/share.ts +143 -48
  88. package/src/cli/sync.test.ts +150 -1
  89. package/src/cli/sync.ts +25 -29
  90. package/src/journal.test.ts +45 -0
  91. package/src/journal.ts +18 -1
  92. package/src/personal-vault.ts +23 -0
  93. package/src/qmd-reindex.test.ts +324 -4
  94. package/src/qmd-reindex.ts +414 -76
  95. package/src/s3.test.ts +79 -0
  96. package/src/s3.ts +48 -1
  97. package/src/telemetry.test.ts +128 -0
  98. package/src/telemetry.ts +80 -0
  99. package/src/watcher.ts +111 -14
  100. package/test/e2e/watcher-real-chokidar.test.ts +62 -2
  101. package/test/e2e/watcher-recursive-backend.test.ts +67 -1
@@ -33,9 +33,20 @@ import type {
33
33
  VaultClientSurface,
34
34
  WatcherSurface,
35
35
  } from "./sync-runner.js";
36
- import { FakeClock } from "../watcher.js";
37
- import { adaptivePollMs } from "./sync-runner-watch-loop.js";
38
- import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
36
+ import {
37
+ FakeClock,
38
+ type LocalDeleteSnapshot,
39
+ type TreeChangeBatch,
40
+ } from "../watcher.js";
41
+ import {
42
+ adaptivePollMs,
43
+ journalEntriesForWatcherDelete,
44
+ } from "./sync-runner-watch-loop.js";
45
+ import {
46
+ PERSONAL_VAULT_JOURNAL_SLUG,
47
+ readJournal,
48
+ writeJournal,
49
+ } from "../journal.js";
39
50
  import { HQ_CLOUD_VERSION } from "../version.js";
40
51
  import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
41
52
  import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
@@ -231,6 +242,9 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
231
242
  updated: true,
232
243
  embedded: false,
233
244
  pendingDirty: false,
245
+ lockBusy: false,
246
+ timedOut: false,
247
+ corruptionQuarantined: false,
234
248
  })),
235
249
  reconcileManifest: vi
236
250
  .fn()
@@ -2159,6 +2173,9 @@ describe("all-complete aggregate", () => {
2159
2173
  updated: true,
2160
2174
  embedded: false,
2161
2175
  pendingDirty: false,
2176
+ lockBusy: false,
2177
+ timedOut: false,
2178
+ corruptionQuarantined: false,
2162
2179
  }));
2163
2180
  const deps = makeDeps({
2164
2181
  createVaultClient: () =>
@@ -5577,13 +5594,13 @@ describe("runRunnerWithLoop — operation lock", () => {
5577
5594
  // pass (events must never announce bytes that are not in S3 yet).
5578
5595
 
5579
5596
  interface BatchWatcherStub extends WatcherSurface {
5580
- emit(changedRelPath?: string, batch?: { paths: Map<string, string> }): void;
5597
+ emit(changedRelPath?: string, batch?: TreeChangeBatch): void;
5581
5598
  disposed: boolean;
5582
5599
  }
5583
5600
 
5584
5601
  function makeBatchWatcherStub(): BatchWatcherStub {
5585
5602
  const listeners = new Set<
5586
- (p?: string, b?: { paths: Map<string, string> }) => void
5603
+ (p?: string, b?: TreeChangeBatch) => void
5587
5604
  >();
5588
5605
  return {
5589
5606
  disposed: false,
@@ -5597,7 +5614,7 @@ function makeBatchWatcherStub(): BatchWatcherStub {
5597
5614
  this.disposed = true;
5598
5615
  listeners.clear();
5599
5616
  },
5600
- emit(changedRelPath?: string, batch?: { paths: Map<string, string> }) {
5617
+ emit(changedRelPath?: string, batch?: TreeChangeBatch) {
5601
5618
  for (const l of [...listeners]) l(changedRelPath, batch);
5602
5619
  },
5603
5620
  };
@@ -5774,6 +5791,94 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
5774
5791
  await loop;
5775
5792
  });
5776
5793
 
5794
+ it("does not publish an exit-zero deletion that the scoped push refused", async () => {
5795
+ const publishBatch = vi.fn();
5796
+ const startEventSync = vi.fn().mockResolvedValue({
5797
+ publishBatch,
5798
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
5799
+ ownDeviceId: "dev-test",
5800
+ dispose: vi.fn().mockResolvedValue(undefined),
5801
+ });
5802
+ const runPass = vi
5803
+ .fn()
5804
+ .mockResolvedValueOnce(0)
5805
+ .mockResolvedValueOnce({
5806
+ exitCode: 0,
5807
+ result: {
5808
+ pushPathResults: [{
5809
+ relativePath: "companies/indigo/deleted.md",
5810
+ status: "refused",
5811
+ operation: "delete",
5812
+ reason: "missing-delete-intent",
5813
+ }],
5814
+ },
5815
+ })
5816
+ .mockResolvedValue(0);
5817
+ const { loop, watcher, tick, shutdown } = runLoop({
5818
+ claims: ENROLLED_CLAIMS,
5819
+ startEventSync,
5820
+ runPass,
5821
+ });
5822
+ await microtasks();
5823
+ watcher.emit("companies/indigo/deleted.md", {
5824
+ paths: new Map([[
5825
+ "/tmp/hq/companies/indigo/deleted.md",
5826
+ "companies/indigo/deleted.md",
5827
+ ]]),
5828
+ });
5829
+ tick();
5830
+ await microtasks();
5831
+
5832
+ expect(publishBatch).not.toHaveBeenCalled();
5833
+ shutdown();
5834
+ await loop;
5835
+ });
5836
+
5837
+ it("does not publish unlinkDir when a covered descendant delete was refused", async () => {
5838
+ const publishBatch = vi.fn();
5839
+ const startEventSync = vi.fn().mockResolvedValue({
5840
+ publishBatch,
5841
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
5842
+ ownDeviceId: "dev-test",
5843
+ dispose: vi.fn().mockResolvedValue(undefined),
5844
+ });
5845
+ const runPass = vi
5846
+ .fn()
5847
+ .mockResolvedValueOnce(0)
5848
+ .mockResolvedValueOnce({
5849
+ exitCode: 0,
5850
+ result: {
5851
+ pushPathResults: [{
5852
+ relativePath: "companies/indigo/archive/deleted.md",
5853
+ status: "refused",
5854
+ operation: "delete",
5855
+ reason: "missing-delete-intent",
5856
+ }],
5857
+ },
5858
+ })
5859
+ .mockResolvedValue(0);
5860
+ const { loop, watcher, tick, shutdown } = runLoop({
5861
+ claims: ENROLLED_CLAIMS,
5862
+ startEventSync,
5863
+ runPass,
5864
+ });
5865
+ await microtasks();
5866
+ const absolutePath = "/tmp/hq/companies/indigo/archive";
5867
+ watcher.emit("companies/indigo/archive", {
5868
+ paths: new Map([[
5869
+ absolutePath,
5870
+ "companies/indigo/archive",
5871
+ ]]),
5872
+ changes: new Map([[absolutePath, { kind: "unlinkDir" }]]),
5873
+ });
5874
+ tick();
5875
+ await microtasks();
5876
+
5877
+ expect(publishBatch).not.toHaveBeenCalled();
5878
+ shutdown();
5879
+ await loop;
5880
+ });
5881
+
5777
5882
  it("F04: mixed-route watcher batches publish only routes that were pushed", async () => {
5778
5883
  const publishBatch = vi.fn();
5779
5884
  const startEventSync = vi.fn().mockResolvedValue({
@@ -6354,6 +6459,77 @@ describe("US-004 — --personal for an AGENT machine identity (HARD GATE)", () =
6354
6459
  "custom:entityUid": AGENT_UID,
6355
6460
  };
6356
6461
 
6462
+ it("uses the newly minted machine ID token for --personal claims when no cache claims are available", async () => {
6463
+ const machineIdToken = `header.${Buffer.from(JSON.stringify(agentClaims)).toString("base64url")}.signature`;
6464
+ const listByType = vi.fn((type: string) =>
6465
+ Promise.resolve(type === "agent" ? [agentSelfEntity] : []),
6466
+ );
6467
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
6468
+ const deps = makeDeps({
6469
+ getAccessToken: vi.fn().mockResolvedValue(machineIdToken),
6470
+ // Simulate a newly minted token before any cache read is available.
6471
+ getIdTokenClaims: () => null,
6472
+ sync: syncSpy as unknown as RunnerDeps["sync"],
6473
+ createVaultClient: () => ({
6474
+ ...makeVaultStub(),
6475
+ entity: { get: () => Promise.resolve(undefined as never), listByType },
6476
+ }),
6477
+ });
6478
+
6479
+ expect(await runRunner(["--personal"], deps)).toBe(0);
6480
+ expect(listByType).toHaveBeenCalledWith("agent");
6481
+ expect(syncSpy).toHaveBeenCalledTimes(1);
6482
+ expect(deps.stderr.events().some((event) => event.type === "auth-error")).toBe(false);
6483
+ });
6484
+
6485
+ it("uses the freshly authenticated machine claims over a stale human cache for --companies", async () => {
6486
+ const machineIdToken = `header.${Buffer.from(JSON.stringify(agentClaims)).toString("base64url")}.signature`;
6487
+ const listByType = vi.fn((type: string) =>
6488
+ Promise.resolve(type === "agent" ? [agentSelfEntity] : []),
6489
+ );
6490
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
6491
+ const deps = makeDeps({
6492
+ getAccessToken: vi.fn().mockResolvedValue(machineIdToken),
6493
+ // A pre-bootstrap human token must not make this machine run select a
6494
+ // person vault (or drop its own personal target altogether).
6495
+ getIdTokenClaims: () => ({ sub: "human-sub" }),
6496
+ sync: syncSpy as unknown as RunnerDeps["sync"],
6497
+ createVaultClient: () => ({
6498
+ ...makeVaultStub({ memberships: [{ companyUid: "cmp_team" }] }),
6499
+ entity: {
6500
+ get: (uid: string) =>
6501
+ Promise.resolve(
6502
+ uid === "cmp_team"
6503
+ ? ({
6504
+ uid,
6505
+ type: "company",
6506
+ slug: "team",
6507
+ bucketName: "hq-vault-cmp-team",
6508
+ status: "active",
6509
+ } as EntityInfo)
6510
+ : (undefined as never),
6511
+ ),
6512
+ listByType,
6513
+ },
6514
+ }),
6515
+ });
6516
+
6517
+ expect(await runRunner(["--companies"], deps)).toBe(0);
6518
+ expect(listByType).toHaveBeenCalledWith("agent");
6519
+ expect(syncSpy).toHaveBeenCalledTimes(2);
6520
+ const plan = deps.stdout.events().find((event) => event.type === "fanout-plan") as Extract<
6521
+ RunnerEvent,
6522
+ { type: "fanout-plan" }
6523
+ >;
6524
+ expect(plan.companies).toContainEqual(
6525
+ expect.objectContaining({
6526
+ uid: AGENT_UID,
6527
+ slug: "personal",
6528
+ personalMode: true,
6529
+ }),
6530
+ );
6531
+ });
6532
+
6357
6533
  // -----------------------------------------------------------------------
6358
6534
  // (1) RESOLUTION — UNBLOCKED by US-013.
6359
6535
  //
@@ -7001,3 +7177,483 @@ describe("US-002 — manifest reconciliation wiring", () => {
7001
7177
  expect(deps.stderr.raw()).not.toContain("manifest-reconciled");
7002
7178
  });
7003
7179
  });
7180
+
7181
+ describe("watcher delete intents without pre-seeded authorization", () => {
7182
+ function startDeleteHarness(
7183
+ keys: string[],
7184
+ options: { gateInitialPull?: boolean } = {},
7185
+ ) {
7186
+ const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-delete-"));
7187
+ const companyRoot = path.join(hqRoot, "companies", "indigo");
7188
+ fs.mkdirSync(companyRoot, { recursive: true });
7189
+ const remote = new Map<string, string>();
7190
+ const files: Record<string, {
7191
+ hash: string; size: number; syncedAt: string; direction: "down";
7192
+ remoteEtag: string; kind: "file";
7193
+ }> = {};
7194
+ for (const key of keys) {
7195
+ const absolutePath = path.join(companyRoot, key);
7196
+ const content = `synced:${key}`;
7197
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
7198
+ fs.writeFileSync(absolutePath, content);
7199
+ remote.set(key, content);
7200
+ files[key] = {
7201
+ hash: `hash:${key}`,
7202
+ size: Buffer.byteLength(content),
7203
+ syncedAt: new Date().toISOString(),
7204
+ direction: "down",
7205
+ remoteEtag: `etag:${key}`,
7206
+ kind: "file",
7207
+ };
7208
+ }
7209
+ writeJournal("indigo", {
7210
+ version: "2", lastSync: new Date().toISOString(), files, pulls: [],
7211
+ });
7212
+
7213
+ const watcher = makeBatchWatcherStub();
7214
+ const sleep = makeSteppableSleep();
7215
+ let capture: ((
7216
+ relativePath: string,
7217
+ kind: "unlink" | "unlinkDir",
7218
+ ) => LocalDeleteSnapshot[]) | undefined;
7219
+ let triggerShutdown = () => {};
7220
+ let downloads = 0;
7221
+ let releaseInitialPull = () => {};
7222
+ const initialPullGate = new Promise<void>((resolve) => {
7223
+ releaseInitialPull = resolve;
7224
+ });
7225
+ let initialPullGated = options.gateInitialPull === true;
7226
+ const runPass = vi.fn(async (argv: string[]) => {
7227
+ const direction = argv[argv.indexOf("--direction") + 1];
7228
+ if (direction === "push") {
7229
+ const journal = readJournal("indigo");
7230
+ const pathResults: Array<{
7231
+ relativePath: string; status: "accepted" | "refused";
7232
+ operation: "delete"; reason?: string;
7233
+ }> = [];
7234
+ const scopes = argv.flatMap((value, index) =>
7235
+ argv[index - 1] === "--scope-path" ? [value] : [],
7236
+ );
7237
+ for (const scope of scopes) {
7238
+ for (const key of Object.keys(journal.files)) {
7239
+ if (key !== scope && !key.startsWith(`${scope}/`)) continue;
7240
+ const absolutePath = path.join(companyRoot, key);
7241
+ const entry = journal.files[key];
7242
+ if (fs.existsSync(absolutePath)) {
7243
+ remote.set(key, fs.readFileSync(absolutePath, "utf8"));
7244
+ continue;
7245
+ }
7246
+ const intent = entry.localDeleteIntent;
7247
+ const current =
7248
+ intent?.remoteEtag === entry.remoteEtag &&
7249
+ intent?.localHash === entry.hash &&
7250
+ intent?.localKind === entry.kind;
7251
+ if (current) {
7252
+ remote.delete(key);
7253
+ delete journal.files[key];
7254
+ pathResults.push({
7255
+ relativePath: `companies/indigo/${key}`,
7256
+ status: "accepted",
7257
+ operation: "delete",
7258
+ });
7259
+ } else {
7260
+ pathResults.push({
7261
+ relativePath: `companies/indigo/${key}`,
7262
+ status: "refused",
7263
+ operation: "delete",
7264
+ reason: "missing-delete-intent",
7265
+ });
7266
+ }
7267
+ }
7268
+ const added = path.join(companyRoot, scope);
7269
+ if (fs.existsSync(added) && fs.lstatSync(added).isFile()) {
7270
+ remote.set(scope, fs.readFileSync(added, "utf8"));
7271
+ }
7272
+ }
7273
+ writeJournal("indigo", journal);
7274
+ return { exitCode: 0, result: { pushPathResults: pathResults } };
7275
+ }
7276
+ if (direction === "pull" || direction === "both") {
7277
+ if (initialPullGated) {
7278
+ initialPullGated = false;
7279
+ await initialPullGate;
7280
+ }
7281
+ const journal = readJournal("indigo");
7282
+ for (const [key, content] of remote) {
7283
+ const absolutePath = path.join(companyRoot, key);
7284
+ if (fs.existsSync(absolutePath)) continue;
7285
+ const entry = journal.files[key];
7286
+ const intent = entry?.localDeleteIntent;
7287
+ const currentIntent =
7288
+ intent?.remoteEtag === entry?.remoteEtag &&
7289
+ intent?.localHash === entry?.hash &&
7290
+ intent?.localKind === entry?.kind;
7291
+ if (currentIntent) continue;
7292
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
7293
+ fs.writeFileSync(absolutePath, content);
7294
+ downloads++;
7295
+ }
7296
+ }
7297
+ return 0;
7298
+ });
7299
+ const loop = runRunnerWithLoop(
7300
+ [
7301
+ "--companies", "--watch", "--event-push", "--direction", "both",
7302
+ "--hq-root", hqRoot, "--poll-remote-ms", "60000",
7303
+ ],
7304
+ {
7305
+ runPass,
7306
+ createWatcher: (opts) => {
7307
+ capture = opts.captureLocalDeleteSnapshots;
7308
+ return watcher;
7309
+ },
7310
+ sleep: sleep.sleep,
7311
+ onShutdownSignal: (handler) => {
7312
+ triggerShutdown = handler;
7313
+ return () => {};
7314
+ },
7315
+ },
7316
+ );
7317
+ return {
7318
+ hqRoot, companyRoot, remote, watcher, loop, tick: sleep.tick,
7319
+ capture: () => capture!, downloads: () => downloads,
7320
+ releaseInitialPull,
7321
+ shutdown: () => triggerShutdown(),
7322
+ };
7323
+ }
7324
+
7325
+ async function settle(): Promise<void> {
7326
+ await flushLoopMicrotasks(12);
7327
+ }
7328
+
7329
+ async function stopHarness(h: ReturnType<typeof startDeleteHarness>) {
7330
+ h.shutdown();
7331
+ h.tick();
7332
+ await h.loop;
7333
+ fs.rmSync(h.hqRoot, { recursive: true, force: true });
7334
+ }
7335
+
7336
+ it("live unlink binds the pre-delete revision, deletes remote, and survives immediate plus later full pulls", async () => {
7337
+ const h = startDeleteHarness(["knowledge/gone.md"]);
7338
+ await settle();
7339
+ const absolutePath = path.join(h.companyRoot, "knowledge/gone.md");
7340
+ fs.unlinkSync(absolutePath);
7341
+ const snapshots = h.capture()(
7342
+ "companies/indigo/knowledge/gone.md", "unlink",
7343
+ );
7344
+ expect(snapshots).toHaveLength(1);
7345
+ expect(readJournal("indigo").files["knowledge/gone.md"]?.localDeleteIntent)
7346
+ .toMatchObject({
7347
+ remoteEtag: "etag:knowledge/gone.md",
7348
+ localHash: "hash:knowledge/gone.md",
7349
+ localKind: "file",
7350
+ });
7351
+ h.watcher.emit("companies/indigo/knowledge/gone.md", {
7352
+ paths: new Map([[absolutePath, "companies/indigo/knowledge/gone.md"]]),
7353
+ changes: new Map([
7354
+ [absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
7355
+ ]),
7356
+ } as TreeChangeBatch);
7357
+ h.tick();
7358
+ await settle();
7359
+ expect(h.remote.has("knowledge/gone.md")).toBe(false);
7360
+ expect(fs.existsSync(absolutePath)).toBe(false);
7361
+ expect(h.downloads()).toBe(0);
7362
+
7363
+ for (let i = 0; i < 8; i++) {
7364
+ h.tick();
7365
+ await settle();
7366
+ }
7367
+ expect(fs.existsSync(absolutePath)).toBe(false);
7368
+ expect(h.downloads()).toBe(0);
7369
+ await stopHarness(h);
7370
+ });
7371
+
7372
+ it("persists the intent before an in-flight pull plans the missing path", async () => {
7373
+ const h = startDeleteHarness(
7374
+ ["knowledge/in-flight.md"],
7375
+ { gateInitialPull: true },
7376
+ );
7377
+ await settle();
7378
+ const relativePath = "companies/indigo/knowledge/in-flight.md";
7379
+ const absolutePath = path.join(
7380
+ h.companyRoot,
7381
+ "knowledge/in-flight.md",
7382
+ );
7383
+ fs.unlinkSync(absolutePath);
7384
+ const snapshots = h.capture()(relativePath, "unlink");
7385
+
7386
+ expect(snapshots).toHaveLength(1);
7387
+ expect(
7388
+ readJournal("indigo").files["knowledge/in-flight.md"]
7389
+ ?.localDeleteIntent,
7390
+ ).toBeDefined();
7391
+
7392
+ h.watcher.emit(relativePath, {
7393
+ paths: new Map([[absolutePath, relativePath]]),
7394
+ changes: new Map([
7395
+ [absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
7396
+ ]),
7397
+ } as TreeChangeBatch);
7398
+ h.releaseInitialPull();
7399
+ await settle();
7400
+
7401
+ expect(h.downloads()).toBe(0);
7402
+ expect(fs.existsSync(absolutePath)).toBe(false);
7403
+
7404
+ h.tick();
7405
+ await settle();
7406
+ expect(h.remote.has("knowledge/in-flight.md")).toBe(false);
7407
+ expect(h.downloads()).toBe(0);
7408
+ await stopHarness(h);
7409
+ });
7410
+
7411
+ it("uses direct journal-key lookup for file unlink snapshots", () => {
7412
+ const exactEntry = {
7413
+ hash: "hash",
7414
+ size: 4,
7415
+ syncedAt: new Date().toISOString(),
7416
+ direction: "down" as const,
7417
+ remoteEtag: "etag",
7418
+ kind: "file" as const,
7419
+ };
7420
+ const files = new Proxy(
7421
+ { "knowledge/exact.md": exactEntry },
7422
+ {
7423
+ ownKeys: () => {
7424
+ throw new Error("file unlink must not enumerate the journal");
7425
+ },
7426
+ },
7427
+ );
7428
+
7429
+ expect(
7430
+ journalEntriesForWatcherDelete(
7431
+ files,
7432
+ "knowledge/exact.md",
7433
+ "unlink",
7434
+ ),
7435
+ ).toEqual([["knowledge/exact.md", exactEntry]]);
7436
+ });
7437
+
7438
+ it("keeps a deleted top-level personal file in the scoped push delete roots", async () => {
7439
+ const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-personal-delete-"));
7440
+ const key = "personal-note.md";
7441
+ const absolutePath = path.join(hqRoot, key);
7442
+ fs.writeFileSync(absolutePath, "personal content");
7443
+ const remote = new Map([[key, "personal content"]]);
7444
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
7445
+ version: "2",
7446
+ lastSync: new Date().toISOString(),
7447
+ pulls: [],
7448
+ files: {
7449
+ [key]: {
7450
+ hash: "personal-hash",
7451
+ size: 16,
7452
+ syncedAt: new Date().toISOString(),
7453
+ direction: "down",
7454
+ remoteEtag: "personal-etag",
7455
+ kind: "file",
7456
+ },
7457
+ },
7458
+ });
7459
+
7460
+ const shareSpy = vi.fn(async (options: ShareOptions) => {
7461
+ const journal = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
7462
+ const entry = journal.files[key];
7463
+ const intent = entry?.localDeleteIntent;
7464
+ const currentIntent =
7465
+ intent?.remoteEtag === entry?.remoteEtag &&
7466
+ intent?.localHash === entry?.hash &&
7467
+ intent?.localKind === entry?.kind;
7468
+ if (
7469
+ !fs.existsSync(absolutePath) &&
7470
+ options.deleteScopeRoots?.includes(key) &&
7471
+ currentIntent
7472
+ ) {
7473
+ remote.delete(key);
7474
+ delete journal.files[key];
7475
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, journal);
7476
+ return defaultShareResult({
7477
+ filesDeleted: 1,
7478
+ pathResults: [
7479
+ { path: key, status: "accepted", operation: "delete" },
7480
+ ],
7481
+ });
7482
+ }
7483
+ return defaultShareResult();
7484
+ });
7485
+ let downloads = 0;
7486
+ const syncSpy = vi.fn(async () => {
7487
+ if (remote.has(key) && !fs.existsSync(absolutePath)) {
7488
+ const entry =
7489
+ readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files[key];
7490
+ const intent = entry?.localDeleteIntent;
7491
+ const currentIntent =
7492
+ intent?.remoteEtag === entry?.remoteEtag &&
7493
+ intent?.localHash === entry?.hash &&
7494
+ intent?.localKind === entry?.kind;
7495
+ if (!currentIntent) {
7496
+ fs.writeFileSync(absolutePath, remote.get(key)!);
7497
+ downloads++;
7498
+ }
7499
+ }
7500
+ return defaultSyncResult();
7501
+ });
7502
+ const runnerDeps = makeDeps({
7503
+ share: shareSpy,
7504
+ sync: syncSpy,
7505
+ createVaultClient: () =>
7506
+ makeVaultStub({
7507
+ listPersons: () =>
7508
+ Promise.resolve([
7509
+ {
7510
+ uid: "prs_personal",
7511
+ type: "person",
7512
+ bucketName: "hq-vault-personal",
7513
+ status: "active",
7514
+ } as unknown as EntityInfo,
7515
+ ]),
7516
+ }),
7517
+ operationLockAlreadyHeld: true,
7518
+ });
7519
+ const watcher = makeBatchWatcherStub();
7520
+ const sleep = makeSteppableSleep();
7521
+ let capture: ((
7522
+ relativePath: string,
7523
+ kind: "unlink" | "unlinkDir",
7524
+ ) => LocalDeleteSnapshot[]) | undefined;
7525
+ let triggerShutdown = () => {};
7526
+ const loop = runRunnerWithLoop(
7527
+ [
7528
+ "--personal",
7529
+ "--watch",
7530
+ "--event-push",
7531
+ "--direction",
7532
+ "both",
7533
+ "--hq-root",
7534
+ hqRoot,
7535
+ "--poll-remote-ms",
7536
+ "60000",
7537
+ ],
7538
+ {
7539
+ runPass: (passArgv) => runRunner(passArgv, runnerDeps),
7540
+ createWatcher: (options) => {
7541
+ capture = options.captureLocalDeleteSnapshots;
7542
+ return watcher;
7543
+ },
7544
+ sleep: sleep.sleep,
7545
+ onShutdownSignal: (handler) => {
7546
+ triggerShutdown = handler;
7547
+ return () => {};
7548
+ },
7549
+ },
7550
+ );
7551
+ await vi.waitFor(() => expect(syncSpy).toHaveBeenCalled());
7552
+ await settle();
7553
+ shareSpy.mockClear();
7554
+
7555
+ fs.unlinkSync(absolutePath);
7556
+ const snapshots = capture!(key, "unlink");
7557
+ expect(snapshots).toHaveLength(1);
7558
+ expect(snapshots[0].deleteScopeRoot).toBe(key);
7559
+ expect(
7560
+ readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files[key]
7561
+ ?.localDeleteIntent,
7562
+ ).toBeDefined();
7563
+ watcher.emit(key, {
7564
+ paths: new Map([[absolutePath, key]]),
7565
+ changes: new Map([
7566
+ [absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
7567
+ ]),
7568
+ } as TreeChangeBatch);
7569
+ sleep.tick();
7570
+ await settle();
7571
+
7572
+ expect(remote.has(key)).toBe(false);
7573
+ expect(downloads).toBe(0);
7574
+ expect(fs.existsSync(absolutePath)).toBe(false);
7575
+ const scopedShareOptions = shareSpy.mock.calls.at(-1)?.[0];
7576
+ expect(scopedShareOptions?.paths).not.toContain(absolutePath);
7577
+ expect(scopedShareOptions?.deleteScopeRoots).toEqual([key]);
7578
+
7579
+ triggerShutdown();
7580
+ await loop;
7581
+ fs.rmSync(hqRoot, { recursive: true, force: true });
7582
+ });
7583
+
7584
+ it("rename uploads the new path, deletes the old path, and neither resurrects", async () => {
7585
+ const h = startDeleteHarness(["knowledge/old.md"]);
7586
+ await settle();
7587
+ const oldPath = path.join(h.companyRoot, "knowledge/old.md");
7588
+ const newPath = path.join(h.companyRoot, "knowledge/new.md");
7589
+ const snapshots = h.capture()(
7590
+ "companies/indigo/knowledge/old.md", "unlink",
7591
+ );
7592
+ fs.renameSync(oldPath, newPath);
7593
+ h.watcher.emit("companies/indigo/knowledge/old.md", {
7594
+ paths: new Map([
7595
+ [oldPath, "companies/indigo/knowledge/old.md"],
7596
+ [newPath, "companies/indigo/knowledge/new.md"],
7597
+ ]),
7598
+ changes: new Map([
7599
+ [oldPath, { kind: "unlink", deleteSnapshots: snapshots }],
7600
+ [newPath, { kind: "add" }],
7601
+ ]),
7602
+ } as TreeChangeBatch);
7603
+ h.tick();
7604
+ await settle();
7605
+ expect(h.remote.has("knowledge/old.md")).toBe(false);
7606
+ expect(h.remote.get("knowledge/new.md")).toBe("synced:knowledge/old.md");
7607
+ expect(fs.existsSync(oldPath)).toBe(false);
7608
+ expect(fs.existsSync(newPath)).toBe(true);
7609
+ await stopHarness(h);
7610
+ });
7611
+
7612
+ it("unlinkDir records every tracked descendant across a 12-file breaker-sized subtree", async () => {
7613
+ const keys = Array.from(
7614
+ { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
7615
+ );
7616
+ const h = startDeleteHarness(keys);
7617
+ await settle();
7618
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
7619
+ const snapshots = h.capture()(
7620
+ "companies/indigo/knowledge/moved", "unlinkDir",
7621
+ );
7622
+ expect(snapshots).toHaveLength(12);
7623
+ fs.rmSync(subtree, { recursive: true, force: true });
7624
+ h.watcher.emit("companies/indigo/knowledge/moved", {
7625
+ paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
7626
+ changes: new Map([
7627
+ [subtree, { kind: "unlinkDir", deleteSnapshots: snapshots }],
7628
+ ]),
7629
+ } as TreeChangeBatch);
7630
+ h.tick();
7631
+ await settle();
7632
+ expect(h.remote.size).toBe(0);
7633
+ expect(Object.keys(readJournal("indigo").files)).toHaveLength(0);
7634
+ await stopHarness(h);
7635
+ });
7636
+
7637
+ it("concurrent recreation before delete execution invalidates the observation and preserves remote", async () => {
7638
+ const h = startDeleteHarness(["knowledge/race.md"]);
7639
+ await settle();
7640
+ const absolutePath = path.join(h.companyRoot, "knowledge/race.md");
7641
+ fs.unlinkSync(absolutePath);
7642
+ const snapshots = h.capture()(
7643
+ "companies/indigo/knowledge/race.md", "unlink",
7644
+ );
7645
+ h.watcher.emit("companies/indigo/knowledge/race.md", {
7646
+ paths: new Map([[absolutePath, "companies/indigo/knowledge/race.md"]]),
7647
+ changes: new Map([
7648
+ [absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
7649
+ ]),
7650
+ } as TreeChangeBatch);
7651
+ fs.writeFileSync(absolutePath, "concurrent recreation");
7652
+ h.tick();
7653
+ await settle();
7654
+ expect(h.remote.has("knowledge/race.md")).toBe(true);
7655
+ expect(readJournal("indigo").files["knowledge/race.md"]?.localDeleteIntent)
7656
+ .toBeUndefined();
7657
+ await stopHarness(h);
7658
+ });
7659
+ });