@indigoai-us/hq-cloud 6.2.1 → 6.2.2
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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +83 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +91 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +105 -1
- package/src/cli/share.ts +102 -8
- package/src/cli/sync.ts +1 -1
package/src/cli/share.test.ts
CHANGED
|
@@ -39,7 +39,7 @@ vi.mock("readline", () => ({
|
|
|
39
39
|
|
|
40
40
|
import * as readline from "readline";
|
|
41
41
|
import { share, _testing as shareTesting } from "./share.js";
|
|
42
|
-
import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
42
|
+
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
43
43
|
import type { EntityContext } from "../types.js";
|
|
44
44
|
|
|
45
45
|
const mockConfig: VaultServiceConfig = {
|
|
@@ -482,6 +482,110 @@ describe("share", () => {
|
|
|
482
482
|
expect(result.filesUploaded).toBe(0);
|
|
483
483
|
});
|
|
484
484
|
|
|
485
|
+
it("fresh-install multipart collision: byte-identical remote is NOT a conflict (reconciled, no mirror)", async () => {
|
|
486
|
+
// Root cause of "HQ installs with conflicts": on a first push the
|
|
487
|
+
// journal is empty, so the fresh-collision branch decides conflict-vs-
|
|
488
|
+
// identical from the remote etag alone. A multipart remote etag
|
|
489
|
+
// (\`<md5>-<partCount>\`) is NOT a content hash, so the OLD code assumed
|
|
490
|
+
// a collision for ANY multipart object — minting a false conflict for
|
|
491
|
+
// the most common fresh-install case: re-pushing a byte-identical
|
|
492
|
+
// core/ scaffold file whose remote copy happened to be multipart-
|
|
493
|
+
// uploaded. The fix fetches the remote bytes once and compares content
|
|
494
|
+
// hashes; identical content reconciles (journal re-stamped, PUT
|
|
495
|
+
// skipped) instead of producing a conflict + \`.conflict-*\` mirror.
|
|
496
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
497
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
498
|
+
const testFile = path.join(companyRoot, "core-scaffold.md");
|
|
499
|
+
const scaffoldBytes = "identical-scaffold-content-shipped-to-every-user";
|
|
500
|
+
fs.writeFileSync(testFile, scaffoldBytes);
|
|
501
|
+
|
|
502
|
+
// Remote exists with a MULTIPART etag and NO journal entry (first push).
|
|
503
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
504
|
+
lastModified: new Date(),
|
|
505
|
+
etag: '"d41d8cd98f00b204e9800998ecf8427e-2"',
|
|
506
|
+
size: scaffoldBytes.length,
|
|
507
|
+
});
|
|
508
|
+
// The convergence probe downloads the remote bytes — which are
|
|
509
|
+
// byte-identical to local — so the content hashes match.
|
|
510
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
511
|
+
fs.writeFileSync(dest as string, scaffoldBytes);
|
|
512
|
+
return undefined as never;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const events: unknown[] = [];
|
|
516
|
+
const result = await share({
|
|
517
|
+
paths: [testFile],
|
|
518
|
+
company: "acme",
|
|
519
|
+
vaultConfig: mockConfig,
|
|
520
|
+
hqRoot: tmpDir,
|
|
521
|
+
onConflict: "keep",
|
|
522
|
+
onEvent: (e) => events.push(e),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// No conflict, no upload (bytes already there), counted as a skip.
|
|
526
|
+
expect(result.conflictPaths).toEqual([]);
|
|
527
|
+
expect(result.filesUploaded).toBe(0);
|
|
528
|
+
expect(result.filesSkipped).toBe(1);
|
|
529
|
+
const conflicts = events.filter(
|
|
530
|
+
(e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
531
|
+
);
|
|
532
|
+
expect(conflicts).toHaveLength(0);
|
|
533
|
+
const reconciled = events.filter(
|
|
534
|
+
(e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "reconciled",
|
|
535
|
+
);
|
|
536
|
+
expect(reconciled).toHaveLength(1);
|
|
537
|
+
expect(reconciled[0]).toMatchObject({ path: "core-scaffold.md", direction: "push" });
|
|
538
|
+
// No conflict-mirror litter on disk.
|
|
539
|
+
const litter = fs
|
|
540
|
+
.readdirSync(companyRoot)
|
|
541
|
+
.filter((n) => n.includes(".conflict-"));
|
|
542
|
+
expect(litter).toEqual([]);
|
|
543
|
+
// Local file untouched.
|
|
544
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(scaffoldBytes);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("fresh-install multipart collision: genuinely different remote IS a conflict (Bug #7 preserved)", async () => {
|
|
548
|
+
// The convergence probe must not weaken Bug #7 protection: a multipart
|
|
549
|
+
// remote whose CONTENT actually differs from local is still a real
|
|
550
|
+
// fresh collision and must surface a conflict (so a peer's content
|
|
551
|
+
// isn't silently clobbered).
|
|
552
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
553
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
554
|
+
const testFile = path.join(companyRoot, "diverged-multipart.md");
|
|
555
|
+
fs.writeFileSync(testFile, "my-local-version");
|
|
556
|
+
|
|
557
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
558
|
+
lastModified: new Date(),
|
|
559
|
+
etag: '"0123456789abcdef0123456789abcdef-3"',
|
|
560
|
+
size: 32,
|
|
561
|
+
});
|
|
562
|
+
// The probe (and the subsequent keep-mirror download) return DIFFERENT
|
|
563
|
+
// remote bytes.
|
|
564
|
+
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
565
|
+
fs.writeFileSync(dest as string, "a-peers-different-version");
|
|
566
|
+
return undefined as never;
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const events: unknown[] = [];
|
|
570
|
+
const result = await share({
|
|
571
|
+
paths: [testFile],
|
|
572
|
+
company: "acme",
|
|
573
|
+
vaultConfig: mockConfig,
|
|
574
|
+
hqRoot: tmpDir,
|
|
575
|
+
onConflict: "keep",
|
|
576
|
+
onEvent: (e) => events.push(e),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(result.conflictPaths).toEqual(["diverged-multipart.md"]);
|
|
580
|
+
expect(result.filesUploaded).toBe(0);
|
|
581
|
+
const conflicts = events.filter(
|
|
582
|
+
(e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
583
|
+
);
|
|
584
|
+
expect(conflicts).toHaveLength(1);
|
|
585
|
+
// Local file untouched under keep.
|
|
586
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
|
|
587
|
+
});
|
|
588
|
+
|
|
485
589
|
it("uploads (no conflict) when only the local side changed since last sync", async () => {
|
|
486
590
|
// Regression for hq-cloud#<conflict-detection>: a local edit to a file
|
|
487
591
|
// that exists on S3 used to trigger a push conflict because the
|
package/src/cli/share.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as fs from "fs";
|
|
9
|
+
import * as os from "os";
|
|
9
10
|
import * as path from "path";
|
|
10
11
|
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
12
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
@@ -45,6 +46,59 @@ import {
|
|
|
45
46
|
} from "../lib/conflict-file.js";
|
|
46
47
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Push-side fresh-collision convergence probe.
|
|
51
|
+
*
|
|
52
|
+
* For a first push (no journal entry) where the remote object already
|
|
53
|
+
* exists AND was multipart-uploaded, the remote etag (`<md5>-<partCount>`)
|
|
54
|
+
* is not a content hash, so we cannot tell "byte-identical" from "genuine
|
|
55
|
+
* divergence" without looking at the bytes. Fetch the remote object once to
|
|
56
|
+
* a throwaway temp file, hash it the same symlink-aware way the planner
|
|
57
|
+
* hashed local, and report whether the two contents DIFFER.
|
|
58
|
+
*
|
|
59
|
+
* Returns `true` on a genuine difference (a real fresh collision) and
|
|
60
|
+
* `false` when the bytes are identical. Fails safe to `true` on any
|
|
61
|
+
* fetch/hash error — a false positive merely prompts the operator, while a
|
|
62
|
+
* false negative could silently clobber a peer's content. Mirrors the
|
|
63
|
+
* pull-side convergence guard in `sync.ts`.
|
|
64
|
+
*
|
|
65
|
+
* The temp file lives in the OS temp dir (never under hqRoot) so it can
|
|
66
|
+
* never round-trip to S3, and is removed in a `finally` regardless of
|
|
67
|
+
* outcome. The name is derived from the relative path so concurrent probes
|
|
68
|
+
* for distinct files never collide on disk.
|
|
69
|
+
*/
|
|
70
|
+
async function remoteContentDiffers(
|
|
71
|
+
ctx: EntityContext,
|
|
72
|
+
relativePath: string,
|
|
73
|
+
localHash: string,
|
|
74
|
+
hqRoot: string,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
const probeKey = crypto
|
|
77
|
+
.createHash("sha256")
|
|
78
|
+
.update(relativePath)
|
|
79
|
+
.digest("hex")
|
|
80
|
+
.slice(0, 16);
|
|
81
|
+
const probePath = path.join(
|
|
82
|
+
os.tmpdir(),
|
|
83
|
+
`hq-conflict-probe-${readShortMachineId(hqRoot)}-${probeKey}.tmp`,
|
|
84
|
+
);
|
|
85
|
+
try {
|
|
86
|
+
await downloadFile(ctx, relativePath, probePath);
|
|
87
|
+
const remoteHash = fs.lstatSync(probePath).isSymbolicLink()
|
|
88
|
+
? hashSymlinkTarget(fs.readlinkSync(probePath))
|
|
89
|
+
: hashFile(probePath);
|
|
90
|
+
return remoteHash !== localHash;
|
|
91
|
+
} catch {
|
|
92
|
+
return true;
|
|
93
|
+
} finally {
|
|
94
|
+
try {
|
|
95
|
+
fs.rmSync(probePath, { force: true });
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort cleanup; a stray temp probe is harmless */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
48
102
|
/**
|
|
49
103
|
* Local-only ephemeral artifacts: conflict-mirror files written by the pull
|
|
50
104
|
* leg whenever a 3-way merge keeps local AND wants to preserve the remote
|
|
@@ -940,12 +994,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
940
994
|
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
|
|
941
995
|
|
|
942
996
|
let isFreshCollision = false;
|
|
997
|
+
let multipartConverged = false;
|
|
943
998
|
if (!journalEntry && item.kind === "file") {
|
|
944
999
|
// Single-part S3 PUT etag is MD5 of the body. Multipart uploads
|
|
945
|
-
// produce \`<md5>-<partCount
|
|
946
|
-
// as ambiguous and DO classify as a conflict (safer for the
|
|
947
|
-
// first-time path — false positives prompt the operator, false
|
|
948
|
-
// negatives lose data). Symlink records (\`kind: "symlink"\`)
|
|
1000
|
+
// produce \`<md5>-<partCount>\`. Symlink records (\`kind: "symlink"\`)
|
|
949
1001
|
// skip the check entirely — the wire body shape (\`hq-symlink:\`
|
|
950
1002
|
// prefix + target) isn't a pure byte mirror and would mis-
|
|
951
1003
|
// classify; symlink overwrites are rare and an audit pass after
|
|
@@ -962,13 +1014,55 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
962
1014
|
// path which is idempotent (S3 will overwrite with identical
|
|
963
1015
|
// content + carry our metadata). Cheap, no behavior change.
|
|
964
1016
|
} else {
|
|
965
|
-
// Multipart
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
|
|
1017
|
+
// Multipart remote etag is \`<md5>-<partCount>\`, NOT a usable
|
|
1018
|
+
// content hash, so — unlike the single-part branch — we cannot
|
|
1019
|
+
// decide collision-vs-identical from the etag alone. The old
|
|
1020
|
+
// behavior assumed a collision here, which minted a FALSE
|
|
1021
|
+
// conflict for the most common fresh-install case: re-pushing a
|
|
1022
|
+
// byte-identical \`core/\` scaffold file whose remote copy happened
|
|
1023
|
+
// to be multipart-uploaded. Every fresh install that hit an
|
|
1024
|
+
// already-populated bucket therefore came up "with conflicts".
|
|
1025
|
+
//
|
|
1026
|
+
// Instead, fetch the remote bytes once and compare content
|
|
1027
|
+
// hashes directly — the same convergence guard the pull side
|
|
1028
|
+
// uses (sync.ts). Identical content is NOT a conflict. On any
|
|
1029
|
+
// fetch/hash failure we fail safe to "conflict" (false positives
|
|
1030
|
+
// prompt the operator; false negatives risk clobbering a peer).
|
|
1031
|
+
const remoteDiffers = await remoteContentDiffers(
|
|
1032
|
+
ctx,
|
|
1033
|
+
relativePath,
|
|
1034
|
+
localHash,
|
|
1035
|
+
hqRoot,
|
|
1036
|
+
);
|
|
1037
|
+
if (remoteDiffers) {
|
|
1038
|
+
isFreshCollision = true;
|
|
1039
|
+
} else {
|
|
1040
|
+
// Byte-identical multipart object already present. Seed the
|
|
1041
|
+
// journal baseline from the remote so the next sync sees no
|
|
1042
|
+
// change on either side, and skip the redundant PUT —
|
|
1043
|
+
// re-uploading would needlessly rewrite remote and churn its
|
|
1044
|
+
// etag from multipart to single-part.
|
|
1045
|
+
multipartConverged = true;
|
|
1046
|
+
}
|
|
969
1047
|
}
|
|
970
1048
|
}
|
|
971
1049
|
|
|
1050
|
+
if (multipartConverged) {
|
|
1051
|
+
const lstat = fs.lstatSync(absolutePath);
|
|
1052
|
+
updateEntry(
|
|
1053
|
+
journal,
|
|
1054
|
+
relativePath,
|
|
1055
|
+
localHash,
|
|
1056
|
+
lstat.size,
|
|
1057
|
+
"up",
|
|
1058
|
+
remoteMeta.etag,
|
|
1059
|
+
lstat.mtimeMs,
|
|
1060
|
+
);
|
|
1061
|
+
emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1062
|
+
filesSkipped++;
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
972
1066
|
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
973
1067
|
conflictPaths.push(relativePath);
|
|
974
1068
|
|