@indigoai-us/hq-cloud 5.10.1 → 5.11.0

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.
@@ -21,7 +21,7 @@ vi.mock("../s3.js", () => ({
21
21
  }));
22
22
 
23
23
  import { share } from "./share.js";
24
- import { headRemoteFile, uploadFile } from "../s3.js";
24
+ import { deleteRemoteFile, headRemoteFile, uploadFile } from "../s3.js";
25
25
  import type { EntityContext } from "../types.js";
26
26
 
27
27
  const mockConfig: VaultServiceConfig = {
@@ -797,4 +797,241 @@ describe("share", () => {
797
797
  filesToSkip: 1,
798
798
  });
799
799
  });
800
+
801
+ // ── Delete propagation (propagateDeletes) ──────────────────────────────────
802
+ //
803
+ // The bug: when a user deletes a local file, the next pull re-downloads it
804
+ // from S3 because the remote object is still listable and the pull plan
805
+ // can't tell "never synced" from "synced then deleted". The fix is to
806
+ // propagate local deletes to S3 on the push side. The vault buckets have
807
+ // versioning enabled, so DeleteObject is soft (a delete-marker becomes the
808
+ // current version; prior object versions remain recoverable).
809
+
810
+ it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
811
+ const companyRoot = path.join(tmpDir, "companies", "acme");
812
+ fs.mkdirSync(companyRoot, { recursive: true });
813
+ // Only "kept.md" exists locally; "gone.md" was previously synced and then
814
+ // deleted by the user.
815
+ fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
816
+
817
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
818
+ fs.writeFileSync(
819
+ journalPath,
820
+ JSON.stringify({
821
+ version: "1",
822
+ lastSync: new Date().toISOString(),
823
+ files: {
824
+ "kept.md": {
825
+ hash: "irrelevant-not-checked-here",
826
+ size: 10,
827
+ syncedAt: new Date().toISOString(),
828
+ direction: "up",
829
+ remoteEtag: "kept-etag",
830
+ },
831
+ "gone.md": {
832
+ hash: "irrelevant-not-checked-here",
833
+ size: 7,
834
+ syncedAt: new Date().toISOString(),
835
+ direction: "up",
836
+ remoteEtag: "gone-etag",
837
+ },
838
+ },
839
+ }),
840
+ );
841
+
842
+ const result = await share({
843
+ paths: [companyRoot],
844
+ company: "acme",
845
+ vaultConfig: mockConfig,
846
+ hqRoot: tmpDir,
847
+ skipUnchanged: true,
848
+ propagateDeletes: true,
849
+ });
850
+
851
+ expect(result.filesDeleted).toBe(1);
852
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
853
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
854
+
855
+ // Journal entry for the gone file is removed; the kept entry stays.
856
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
857
+ expect(journal.files["gone.md"]).toBeUndefined();
858
+ expect(journal.files["kept.md"]).toBeDefined();
859
+ });
860
+
861
+ it("propagateDeletes: emits a `progress` event with deleted:true and bytes from the journal", async () => {
862
+ const companyRoot = path.join(tmpDir, "companies", "acme");
863
+ fs.mkdirSync(companyRoot, { recursive: true });
864
+
865
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
866
+ fs.writeFileSync(
867
+ journalPath,
868
+ JSON.stringify({
869
+ version: "1",
870
+ lastSync: new Date().toISOString(),
871
+ files: {
872
+ "removed.md": {
873
+ hash: "h",
874
+ size: 42,
875
+ syncedAt: new Date().toISOString(),
876
+ direction: "up",
877
+ },
878
+ },
879
+ }),
880
+ );
881
+
882
+ const events: Array<{ type: string; path?: string; bytes?: number; deleted?: boolean }> = [];
883
+ await share({
884
+ paths: [companyRoot],
885
+ company: "acme",
886
+ vaultConfig: mockConfig,
887
+ hqRoot: tmpDir,
888
+ skipUnchanged: true,
889
+ propagateDeletes: true,
890
+ onEvent: (e) => events.push(e as { type: string }),
891
+ });
892
+
893
+ const planEvent = events.find((e) => e.type === "plan") as { filesToDelete?: number } | undefined;
894
+ expect(planEvent?.filesToDelete).toBe(1);
895
+
896
+ const deleteProgress = events.find(
897
+ (e) => e.type === "progress" && e.deleted === true,
898
+ );
899
+ expect(deleteProgress).toMatchObject({
900
+ type: "progress",
901
+ path: "removed.md",
902
+ bytes: 42,
903
+ deleted: true,
904
+ });
905
+ });
906
+
907
+ it("propagateDeletes=false (default): missing local files do NOT trigger a remote delete", async () => {
908
+ const companyRoot = path.join(tmpDir, "companies", "acme");
909
+ fs.mkdirSync(companyRoot, { recursive: true });
910
+
911
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
912
+ fs.writeFileSync(
913
+ journalPath,
914
+ JSON.stringify({
915
+ version: "1",
916
+ lastSync: new Date().toISOString(),
917
+ files: {
918
+ "gone.md": {
919
+ hash: "h",
920
+ size: 7,
921
+ syncedAt: new Date().toISOString(),
922
+ direction: "up",
923
+ },
924
+ },
925
+ }),
926
+ );
927
+
928
+ const result = await share({
929
+ paths: [companyRoot],
930
+ company: "acme",
931
+ vaultConfig: mockConfig,
932
+ hqRoot: tmpDir,
933
+ skipUnchanged: true,
934
+ // propagateDeletes omitted ⇒ defaults to false
935
+ });
936
+
937
+ expect(result.filesDeleted).toBe(0);
938
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
939
+ // Journal entry survives so the next opt-in run can still propagate.
940
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
941
+ expect(journal.files["gone.md"]).toBeDefined();
942
+ });
943
+
944
+ it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
945
+ const companyRoot = path.join(tmpDir, "companies", "acme");
946
+ fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
947
+ fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
948
+
949
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
950
+ fs.writeFileSync(
951
+ journalPath,
952
+ JSON.stringify({
953
+ version: "1",
954
+ lastSync: new Date().toISOString(),
955
+ files: {
956
+ "in-scope/gone.md": {
957
+ hash: "h",
958
+ size: 1,
959
+ syncedAt: new Date().toISOString(),
960
+ direction: "up",
961
+ },
962
+ "other/also-gone.md": {
963
+ hash: "h",
964
+ size: 1,
965
+ syncedAt: new Date().toISOString(),
966
+ direction: "up",
967
+ },
968
+ },
969
+ }),
970
+ );
971
+
972
+ const result = await share({
973
+ paths: [path.join(companyRoot, "in-scope")],
974
+ company: "acme",
975
+ vaultConfig: mockConfig,
976
+ hqRoot: tmpDir,
977
+ skipUnchanged: true,
978
+ propagateDeletes: true,
979
+ });
980
+
981
+ expect(result.filesDeleted).toBe(1);
982
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
983
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
984
+ expect.anything(),
985
+ "in-scope/gone.md",
986
+ );
987
+
988
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
989
+ expect(journal.files["in-scope/gone.md"]).toBeUndefined();
990
+ // Sibling tree's journal entry is untouched — `hq share <subtree>` must
991
+ // not act on files outside the named scope.
992
+ expect(journal.files["other/also-gone.md"]).toBeDefined();
993
+ });
994
+
995
+ it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
996
+ const companyRoot = path.join(tmpDir, "companies", "acme");
997
+ fs.mkdirSync(companyRoot, { recursive: true });
998
+
999
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1000
+ fs.writeFileSync(
1001
+ journalPath,
1002
+ JSON.stringify({
1003
+ version: "1",
1004
+ lastSync: new Date().toISOString(),
1005
+ files: {
1006
+ "flaky.md": {
1007
+ hash: "h",
1008
+ size: 5,
1009
+ syncedAt: new Date().toISOString(),
1010
+ direction: "up",
1011
+ },
1012
+ },
1013
+ }),
1014
+ );
1015
+
1016
+ vi.mocked(deleteRemoteFile).mockRejectedValueOnce(new Error("S3 down"));
1017
+
1018
+ const events: Array<{ type: string; path?: string; message?: string }> = [];
1019
+ const result = await share({
1020
+ paths: [companyRoot],
1021
+ company: "acme",
1022
+ vaultConfig: mockConfig,
1023
+ hqRoot: tmpDir,
1024
+ skipUnchanged: true,
1025
+ propagateDeletes: true,
1026
+ onEvent: (e) => events.push(e as { type: string }),
1027
+ });
1028
+
1029
+ expect(result.filesDeleted).toBe(0);
1030
+ const errorEvent = events.find((e) => e.type === "error");
1031
+ expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
1032
+
1033
+ // Entry survives — next run will retry the delete.
1034
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1035
+ expect(journal.files["flaky.md"]).toBeDefined();
1036
+ });
800
1037
  });
package/src/cli/share.ts CHANGED
@@ -9,8 +9,8 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
11
11
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
- import { uploadFile, headRemoteFile } from "../s3.js";
13
- import { readJournal, writeJournal, hashFile, updateEntry, normalizeEtag } from "../journal.js";
12
+ import { uploadFile, headRemoteFile, deleteRemoteFile } from "../s3.js";
13
+ import { readJournal, writeJournal, hashFile, updateEntry, removeEntry, normalizeEtag } from "../journal.js";
14
14
  import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
15
15
  import { resolveConflict } from "./conflict.js";
16
16
  import type { ConflictStrategy } from "./conflict.js";
@@ -166,12 +166,36 @@ export interface ShareOptions {
166
166
  * hash matches the last-sync state (e.g. to re-heal a bucket).
167
167
  */
168
168
  skipUnchanged?: boolean;
169
+ /**
170
+ * When true, journal entries whose local file is gone trigger a remote
171
+ * `DeleteObject`. Only entries whose key falls under one of the supplied
172
+ * `paths` (after resolution to absolute paths under `companies/{slug}/`)
173
+ * are considered, so `hq share <file>` can never sweep deletes outside
174
+ * the named scope.
175
+ *
176
+ * Vault buckets have versioning enabled, so the delete is soft: a
177
+ * delete-marker becomes the current version and prior object versions
178
+ * remain recoverable indefinitely. The pull-side `listRemoteFiles` skips
179
+ * objects whose current version is a delete-marker (default
180
+ * `ListObjectsV2` behavior), so a deletion stops the next pull from
181
+ * re-downloading the file on this and any other machine.
182
+ *
183
+ * Default false to preserve `hq share <file>` semantics — only the
184
+ * full-tree bidirectional runner opts in.
185
+ */
186
+ propagateDeletes?: boolean;
169
187
  }
170
188
 
171
189
  export interface ShareResult {
172
190
  filesUploaded: number;
173
191
  bytesUploaded: number;
174
192
  filesSkipped: number;
193
+ /**
194
+ * Number of remote `DeleteObject` calls that succeeded this run. Always 0
195
+ * when `propagateDeletes` is false. The corresponding journal entries are
196
+ * removed in the same pass so the next sync sees the key as truly gone.
197
+ */
198
+ filesDeleted: number;
175
199
  /**
176
200
  * Paths (company-relative) that were detected as push conflicts. Mirrors
177
201
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -185,7 +209,7 @@ export interface ShareResult {
185
209
  * Share local file(s) to the entity vault.
186
210
  */
187
211
  export async function share(options: ShareOptions): Promise<ShareResult> {
188
- const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged } = options;
212
+ const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
189
213
  const emit = options.onEvent ?? defaultConsoleLogger;
190
214
 
191
215
  // Exactly-one-of contract: either we vend (vaultConfig) or the caller did
@@ -237,6 +261,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
237
261
  let filesUploaded = 0;
238
262
  let bytesUploaded = 0;
239
263
  let filesSkipped = 0;
264
+ let filesDeleted = 0;
240
265
  const conflictPaths: string[] = [];
241
266
 
242
267
  // Collect all files to share
@@ -249,6 +274,17 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
249
274
  // knowable here). The final `complete` event reports authoritative counts.
250
275
  const plan = computePushPlan(filesToShare, journal, skipUnchanged === true);
251
276
 
277
+ // Delete-propagation plan: walk journal entries whose key falls under the
278
+ // requested scope; any whose local file is gone is a candidate for a
279
+ // remote DeleteObject. Only computed when the caller opts in — `hq share
280
+ // <file>` must never sweep deletes outside the explicit path list.
281
+ const deleteScopeRoots = propagateDeletes === true
282
+ ? resolveDeleteScopeRoots(paths, hqRoot, syncRoot)
283
+ : [];
284
+ const deletePlan = propagateDeletes === true
285
+ ? computeDeletePlan(journal, syncRoot, deleteScopeRoots)
286
+ : [];
287
+
252
288
  emit({
253
289
  type: "plan",
254
290
  // share() is push-only; pull counts are sourced from sync()'s plan event.
@@ -260,6 +296,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
260
296
  // Push conflicts require a remote HEAD; we don't yet do that in Stage 1,
261
297
  // so this stays 0. V1.5 (single LIST) will let us classify them up-front.
262
298
  filesToConflict: 0,
299
+ filesToDelete: deletePlan.length,
263
300
  });
264
301
 
265
302
  // Stage 2: execute. Skip items pre-classified as no-ops, then for each
@@ -329,6 +366,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
329
366
  filesUploaded,
330
367
  bytesUploaded,
331
368
  filesSkipped,
369
+ filesDeleted,
332
370
  conflictPaths,
333
371
  aborted: true,
334
372
  };
@@ -375,6 +413,36 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
375
413
  }
376
414
  }
377
415
 
416
+ // Stage 3: propagate deletes. Each call writes a delete-marker (versioning
417
+ // is enabled on the bucket) and removes the corresponding journal entry so
418
+ // the next sync sees the key as truly gone on this machine. A failed
419
+ // DeleteObject leaves both the journal entry and the remote object intact
420
+ // — the next run will retry.
421
+ for (const relativePath of deletePlan) {
422
+ if (vaultConfig && isExpiringSoon(ctx.expiresAt)) {
423
+ ctx = await refreshEntityContext(companyRef, vaultConfig);
424
+ }
425
+ try {
426
+ const entry = journal.files[relativePath];
427
+ const size = entry?.size ?? 0;
428
+ await deleteRemoteFile(ctx, relativePath);
429
+ removeEntry(journal, relativePath);
430
+ filesDeleted++;
431
+ emit({
432
+ type: "progress",
433
+ path: relativePath,
434
+ bytes: size,
435
+ deleted: true,
436
+ });
437
+ } catch (err) {
438
+ emit({
439
+ type: "error",
440
+ path: relativePath,
441
+ message: err instanceof Error ? err.message : String(err),
442
+ });
443
+ }
444
+ }
445
+
378
446
  // See cli/sync.ts: stamp lastSync on completion so a no-op share still
379
447
  // ticks the "Last sync" indicator.
380
448
  journal.lastSync = new Date().toISOString();
@@ -384,6 +452,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
384
452
  filesUploaded,
385
453
  bytesUploaded,
386
454
  filesSkipped,
455
+ filesDeleted,
387
456
  conflictPaths,
388
457
  aborted: false,
389
458
  };
@@ -401,7 +470,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
401
470
  );
402
471
  }
403
472
  } else if (event.type === "progress") {
404
- if (event.message) {
473
+ if (event.deleted) {
474
+ console.log(` ✗ ${event.path} (deleted)`);
475
+ } else if (event.message) {
405
476
  console.log(` ✓ ${event.path} — "${event.message}"`);
406
477
  } else {
407
478
  console.log(` ✓ ${event.path}`);
@@ -536,3 +607,63 @@ function hasRemoteChanged(
536
607
  const syncedAt = new Date(entry.syncedAt).getTime();
537
608
  return remote.lastModified.getTime() > syncedAt;
538
609
  }
610
+
611
+ /**
612
+ * Resolve each user-supplied share path to a directory under `syncRoot`,
613
+ * returning the company-relative prefix that constrains delete propagation.
614
+ * Files (non-directories) and paths outside the company root are dropped —
615
+ * a delete sweep keyed off a single file or a sibling tree would surprise
616
+ * users who expected deletes to mirror the targeted scope.
617
+ *
618
+ * Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
619
+ * itself; this is the bidirectional-runner case.
620
+ */
621
+ function resolveDeleteScopeRoots(
622
+ paths: string[],
623
+ hqRoot: string,
624
+ syncRoot: string,
625
+ ): string[] {
626
+ const prefixes = new Set<string>();
627
+ for (const p of paths) {
628
+ const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
629
+ if (!fs.existsSync(absolutePath)) continue;
630
+ if (!isWithin(syncRoot, absolutePath)) continue;
631
+ const stat = fs.statSync(absolutePath);
632
+ if (!stat.isDirectory()) continue;
633
+ const rel = path.relative(syncRoot, absolutePath);
634
+ if (rel === "" || rel === ".") {
635
+ return [""];
636
+ }
637
+ prefixes.add(rel.split(path.sep).join("/"));
638
+ }
639
+ return Array.from(prefixes);
640
+ }
641
+
642
+ /**
643
+ * Walk every journal key in `scopeRoots` whose local file is missing from
644
+ * disk, and return the keys to delete. A key is in-scope when it matches
645
+ * (or sits beneath) one of the resolved prefixes. Empty `scopeRoots` ⇒
646
+ * empty plan (caller didn't opt in).
647
+ */
648
+ function computeDeletePlan(
649
+ journal: SyncJournal,
650
+ syncRoot: string,
651
+ scopeRoots: string[],
652
+ ): string[] {
653
+ if (scopeRoots.length === 0) return [];
654
+ const out: string[] = [];
655
+ for (const relativeKey of Object.keys(journal.files)) {
656
+ const inScope = scopeRoots.some(
657
+ (root) =>
658
+ root === "" ||
659
+ relativeKey === root ||
660
+ relativeKey.startsWith(`${root}/`),
661
+ );
662
+ if (!inScope) continue;
663
+ const localPath = path.join(syncRoot, relativeKey);
664
+ if (!fs.existsSync(localPath)) {
665
+ out.push(relativeKey);
666
+ }
667
+ }
668
+ return out;
669
+ }
package/src/cli/sync.ts CHANGED
@@ -56,8 +56,24 @@ export type SyncProgressEvent =
56
56
  * conflict detection requires a remote HEAD that runs in Stage 2.
57
57
  */
58
58
  filesToConflict: number;
59
+ /**
60
+ * Remote keys this run intends to delete from S3 (push-only with
61
+ * `propagateDeletes`; 0 from sync). A key is scheduled for deletion when
62
+ * its journal entry exists, the local file is gone, and the key falls
63
+ * within the share scope. The bucket has versioning enabled so the
64
+ * delete is soft (a delete-marker is written; prior versions remain
65
+ * recoverable).
66
+ */
67
+ filesToDelete: number;
68
+ }
69
+ | {
70
+ type: "progress";
71
+ path: string;
72
+ bytes: number;
73
+ message?: string;
74
+ /** True when this event reports a remote DeleteObject (no upload). */
75
+ deleted?: boolean;
59
76
  }
60
- | { type: "progress"; path: string; bytes: number; message?: string }
61
77
  | { type: "error"; path: string; message: string }
62
78
  | {
63
79
  type: "conflict";
@@ -173,6 +189,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
173
189
  bytesToUpload: 0,
174
190
  filesToSkip: plan.filesToSkip,
175
191
  filesToConflict: plan.filesToConflict,
192
+ filesToDelete: 0,
176
193
  });
177
194
 
178
195
  // Stage 2: execute the plan. Per-item branching mirrors the pre-refactor