@indigoai-us/hq-cloud 5.33.0 → 5.35.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.
package/src/cli/share.ts CHANGED
@@ -9,7 +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, uploadSymlink, headRemoteFile, deleteRemoteFile } from "../s3.js";
12
+ import { uploadFile, uploadSymlink, headRemoteFile, deleteRemoteFile, downloadFile } from "../s3.js";
13
+ import * as crypto from "crypto";
13
14
  import type { UploadAuthor } from "../s3.js";
14
15
  import {
15
16
  readJournal,
@@ -28,6 +29,12 @@ import {
28
29
  import { resolveConflict } from "./conflict.js";
29
30
  import type { ConflictStrategy } from "./conflict.js";
30
31
  import type { SyncProgressEvent } from "./sync.js";
32
+ import {
33
+ buildConflictId,
34
+ buildConflictPath,
35
+ readShortMachineId,
36
+ } from "../lib/conflict-file.js";
37
+ import { appendConflictEntry } from "../lib/conflict-index.js";
31
38
 
32
39
  /**
33
40
  * Local-only ephemeral artifacts: conflict-mirror files written by the pull
@@ -71,16 +78,20 @@ import type { SyncProgressEvent } from "./sync.js";
71
78
  * journaled mirror that's been deleted locally doesn't get included in the
72
79
  * regular delete plan (the dedicated reconcile path handles existing litter).
73
80
  */
74
- const EPHEMERAL_PATH_PATTERN =
81
+ export const EPHEMERAL_PATH_PATTERN =
75
82
  /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-(?:[a-f0-9]+|unknown)(?:\.[^/]*)?$/;
76
83
 
77
84
  /**
78
85
  * Cheap pure check — pass the relative key OR a basename; either works. Used
79
86
  * in both the file walker (basename matching) and the delete-plan walker
80
- * (relative-key matching). The regex matches anywhere in the string, which is
81
- * fine: the `.conflict-<ISO>-<hash>.` token is unambiguous.
87
+ * (relative-key matching), and also by the pull walker in sync.ts to refuse
88
+ * downloading legacy conflict-mirror files that still live in cloud staging
89
+ * (Bug #2 in the 5.33.0 deep-test report — push-side filtered them since
90
+ * 5.33.0 but pull-side downloaded them freely until this export). The regex
91
+ * matches anywhere in the string, which is fine: the
92
+ * `.conflict-<ISO>-<hash>.` token is unambiguous.
82
93
  */
83
- function isEphemeralPath(p: string): boolean {
94
+ export function isEphemeralPath(p: string): boolean {
84
95
  return EPHEMERAL_PATH_PATTERN.test(p);
85
96
  }
86
97
 
@@ -440,6 +451,16 @@ export interface ShareResult {
440
451
  * `currency-gated`.
441
452
  */
442
453
  filesRefusedStale: number;
454
+ /**
455
+ * Paths corresponding to `filesRefusedStale`, capped at 50 to keep the
456
+ * event payload bounded (mirrors `newFiles` capping). Surfaces *which*
457
+ * paths were refused so operators can triage the recurring
458
+ * \`filesRefusedStale: 205\` signal flagged in the 5.33.0 deep-test —
459
+ * the count alone is impossible to investigate because the per-file
460
+ * \`delete-refused-stale-etag\` events vanish from the event stream
461
+ * once the runner has folded them into the totals.
462
+ */
463
+ filesRefusedStalePaths: string[];
443
464
  /**
444
465
  * Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
445
466
  * run (push leg, personalMode=true). Includes both files that would have
@@ -568,6 +589,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
568
589
  // propagateDeletes=false path.
569
590
  let filesTombstoned = 0;
570
591
  let filesRefusedStale = 0;
592
+ // Capped at 50 to bound event payload size — `newFiles` uses the same cap.
593
+ const REFUSED_STALE_PATH_CAP = 50;
594
+ const filesRefusedStalePaths: string[] = [];
571
595
  const conflictPaths: string[] = [];
572
596
 
573
597
  // Collect all files to share
@@ -692,13 +716,58 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
692
716
  // the current remote ETag against the one captured at last sync; when
693
717
  // missing (legacy entries), we fall back to the same `lastModified >
694
718
  // syncedAt` heuristic the pull side uses.
719
+ //
720
+ // Bug #7 (data-loss class — see workspace/reports/hq-cloud-5.33.0-
721
+ // deep-test.md): for a path with NO prior journal entry (first push
722
+ // from this machine), the localChanged/remoteChanged predicates above
723
+ // both evaluate FALSE (their guards require `!!journalEntry`). Push
724
+ // fell through to an unconditional PUT, silently clobbering any
725
+ // peer's content already at that key. The verification report's V7
726
+ // isolated this — the bug is independent of \`--on-conflict\` mode;
727
+ // it's keyed on "do I have a prior journal entry?" not on the flag.
728
+ //
729
+ // Fresh-collision branch: when remoteMeta exists and there's no
730
+ // journal entry, hash the local body (MD5 for parity with S3's
731
+ // single-part etag) and compare. Match → no conflict, silently skip
732
+ // the PUT (the bytes are already there). Mismatch → treat as a
733
+ // conflict in the same shared branch below.
695
734
  const remoteMeta = await headRemoteFile(ctx, relativePath);
696
735
  if (remoteMeta) {
697
736
  const journalEntry = journal.files[relativePath];
698
737
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
699
738
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
700
739
 
701
- if (localChanged && remoteChanged) {
740
+ let isFreshCollision = false;
741
+ if (!journalEntry && item.kind === "file") {
742
+ // Single-part S3 PUT etag is MD5 of the body. Multipart uploads
743
+ // produce \`<md5>-<partCount>\`; we treat any non-single-part etag
744
+ // as ambiguous and DO classify as a conflict (safer for the
745
+ // first-time path — false positives prompt the operator, false
746
+ // negatives lose data). Symlink records (\`kind: "symlink"\`)
747
+ // skip the check entirely — the wire body shape (\`hq-symlink:\`
748
+ // prefix + target) isn't a pure byte mirror and would mis-
749
+ // classify; symlink overwrites are rare and an audit pass after
750
+ // the broader bug-cleanup wave can extend coverage if needed.
751
+ const remoteEtagNormalized = normalizeEtag(remoteMeta.etag);
752
+ const isMultipart = /-\d+$/.test(remoteEtagNormalized);
753
+ if (!isMultipart) {
754
+ const localBody = fs.readFileSync(absolutePath);
755
+ const localMd5 = crypto.createHash("md5").update(localBody).digest("hex");
756
+ if (localMd5 !== remoteEtagNormalized) {
757
+ isFreshCollision = true;
758
+ }
759
+ // Match → bytes are already there; fall through to upload
760
+ // path which is idempotent (S3 will overwrite with identical
761
+ // content + carry our metadata). Cheap, no behavior change.
762
+ } else {
763
+ // Multipart object pre-exists with unknown body shape — assume
764
+ // collision rather than risk a silent overwrite. The operator
765
+ // can resolve via the standard conflict prompt.
766
+ isFreshCollision = true;
767
+ }
768
+ }
769
+
770
+ if ((localChanged && remoteChanged) || isFreshCollision) {
702
771
  conflictPaths.push(relativePath);
703
772
 
704
773
  const resolution = await resolveConflict(
@@ -729,6 +798,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
729
798
  // ShareResult shape stable for consumers that destructure.
730
799
  filesTombstoned,
731
800
  filesRefusedStale,
801
+ // Always present so consumers can destructure without a
802
+ // defaulting fallback. Empty on the abort path because the
803
+ // delete-plan execution loop is short-circuited.
804
+ filesRefusedStalePaths,
732
805
  // Exclusions are computed during the upload walk which has
733
806
  // already completed by the time we hit a per-file conflict-
734
807
  // abort, so the count is meaningful here. No event emit on
@@ -740,6 +813,45 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
740
813
  };
741
814
  }
742
815
  if (resolution === "keep" || resolution === "skip") {
816
+ // Bug #7 mirror branch: when the resolution is keep/skip on a
817
+ // FRESH collision (no prior journal entry), download the
818
+ // remote bytes to \`<orig>.conflict-<ts>-<short>\` so both
819
+ // versions survive on disk. Mirrors the pull-side mirror-write
820
+ // routine in sync.ts exactly. Skipped for stale-journal
821
+ // conflicts (the pre-Bug-#7 codepath) — those already produce
822
+ // a pull-side mirror on the next sync cycle.
823
+ if (isFreshCollision) {
824
+ try {
825
+ const detectedAt = new Date().toISOString();
826
+ const machineId = readShortMachineId(hqRoot);
827
+ const originalRelative = path.relative(hqRoot, absolutePath);
828
+ const conflictRelative = buildConflictPath(
829
+ originalRelative,
830
+ detectedAt,
831
+ machineId,
832
+ );
833
+ const conflictAbs = path.join(hqRoot, conflictRelative);
834
+ await downloadFile(ctx, relativePath, conflictAbs);
835
+ appendConflictEntry(hqRoot, {
836
+ id: buildConflictId(originalRelative, detectedAt),
837
+ originalPath: originalRelative,
838
+ conflictPath: conflictRelative,
839
+ detectedAt,
840
+ side: "push",
841
+ machineId,
842
+ localHash,
843
+ remoteHash: normalizeEtag(remoteMeta.etag),
844
+ });
845
+ } catch (mirrorErr) {
846
+ emit({
847
+ type: "error",
848
+ path: relativePath,
849
+ message: `conflict mirror write failed: ${
850
+ mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)
851
+ }`,
852
+ });
853
+ }
854
+ }
743
855
  filesSkipped++;
744
856
  continue;
745
857
  }
@@ -862,6 +974,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
862
974
  for (const refused of deletePlan.refusedStale) {
863
975
  if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
864
976
  filesRefusedStale++;
977
+ if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
978
+ filesRefusedStalePaths.push(refused.key);
979
+ }
865
980
  emit({
866
981
  type: "delete-refused-stale-etag",
867
982
  path: refused.key,
@@ -901,6 +1016,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
901
1016
  filesDeleted,
902
1017
  filesTombstoned,
903
1018
  filesRefusedStale,
1019
+ filesRefusedStalePaths,
904
1020
  filesExcludedByPolicy: excludedSet.size,
905
1021
  conflictPaths,
906
1022
  aborted: false,