@indigoai-us/hq-cloud 5.33.0 → 5.34.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/dist/bin/sync-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +43 -9
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +69 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +60 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +103 -6
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +78 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +259 -6
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +469 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +7 -1
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +19 -3
- package/dist/ignore.test.js.map +1 -1
- package/dist/s3.d.ts +21 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +69 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +129 -2
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +85 -0
- package/src/bin/sync-runner.ts +52 -9
- package/src/cli/share.test.ts +89 -0
- package/src/cli/share.ts +122 -6
- package/src/cli/sync.test.ts +529 -0
- package/src/cli/sync.ts +294 -7
- package/src/ignore.test.ts +20 -3
- package/src/ignore.ts +7 -1
- package/src/s3.test.ts +142 -2
- package/src/s3.ts +71 -2
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)
|
|
81
|
-
*
|
|
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
|
-
|
|
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,
|