@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/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 +20 -1
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +47 -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 +57 -3
- package/src/ignore.ts +21 -1
- package/src/s3.test.ts +142 -2
- package/src/s3.ts +71 -2
package/src/cli/sync.ts
CHANGED
|
@@ -17,10 +17,12 @@ import {
|
|
|
17
17
|
hashFile,
|
|
18
18
|
hashSymlinkTarget,
|
|
19
19
|
updateEntry,
|
|
20
|
+
removeEntry,
|
|
20
21
|
getEntry,
|
|
21
22
|
normalizeEtag,
|
|
22
23
|
} from "../journal.js";
|
|
23
24
|
import { createIgnoreFilter } from "../ignore.js";
|
|
25
|
+
import { isEphemeralPath } from "./share.js";
|
|
24
26
|
import { resolveConflict } from "./conflict.js";
|
|
25
27
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
26
28
|
import {
|
|
@@ -240,6 +242,26 @@ export interface SyncResult {
|
|
|
240
242
|
newFiles: Array<{ path: string; bytes: number }>;
|
|
241
243
|
/** Convenience count: `newFiles.length`. */
|
|
242
244
|
newFilesCount: number;
|
|
245
|
+
/**
|
|
246
|
+
* Count of remote keys refused at planning time because they matched
|
|
247
|
+
* `EPHEMERAL_PATH_PATTERN` (conflict-mirror files that must never round-
|
|
248
|
+
* trip through the bucket). Mirrors `ShareResult.filesExcludedByPolicy`
|
|
249
|
+
* so push and pull report the same shape. Pre-fix this count was always
|
|
250
|
+
* 0 on the pull side and legacy `.conflict-*` litter rode every sync —
|
|
251
|
+
* see Bug #2 in workspace/reports/hq-cloud-5.33.0-deep-test.md.
|
|
252
|
+
*/
|
|
253
|
+
filesExcludedByPolicy: number;
|
|
254
|
+
/**
|
|
255
|
+
* Count of journal-known keys applied as local deletes during this pull
|
|
256
|
+
* because the remote LIST no longer contains them — the cross-machine
|
|
257
|
+
* delete-propagation signal that Bug #9 closes. The peer's push leg
|
|
258
|
+
* removed the object from S3 (`hq sync` push side verified-to-work in
|
|
259
|
+
* the deep-test addendum), but pre-fix the pull side never enumerated
|
|
260
|
+
* "what's missing-from-remote-that-was-there-before", so the file
|
|
261
|
+
* lingered locally forever. Always 0 when no journal-known keys have
|
|
262
|
+
* disappeared from the remote.
|
|
263
|
+
*/
|
|
264
|
+
filesTombstoned: number;
|
|
243
265
|
}
|
|
244
266
|
|
|
245
267
|
/**
|
|
@@ -279,6 +301,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
279
301
|
let bytesDownloaded = 0;
|
|
280
302
|
let filesSkipped = 0;
|
|
281
303
|
let conflicts = 0;
|
|
304
|
+
let filesTombstoned = 0;
|
|
282
305
|
const conflictPaths: string[] = [];
|
|
283
306
|
|
|
284
307
|
// List all remote files (IAM session policy filters at the AWS layer)
|
|
@@ -322,6 +345,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
322
345
|
filesSkipped++;
|
|
323
346
|
continue;
|
|
324
347
|
}
|
|
348
|
+
if (item.action === "skip-excluded-policy") {
|
|
349
|
+
// Policy-excluded items count separately from `filesSkipped` so the
|
|
350
|
+
// pull result mirrors the push side's `filesExcludedByPolicy`
|
|
351
|
+
// counter — `filesSkipped` stays a measure of "unchanged on this
|
|
352
|
+
// run", not a catch-all for everything we didn't download.
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
325
355
|
|
|
326
356
|
const { remoteFile, localPath } = item;
|
|
327
357
|
|
|
@@ -407,6 +437,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
407
437
|
aborted: true,
|
|
408
438
|
newFiles: plan.newFiles,
|
|
409
439
|
newFilesCount: plan.newFilesCount,
|
|
440
|
+
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
441
|
+
// Abort short-circuits before the tombstone loop runs; report
|
|
442
|
+
// 0 so the field shape stays stable for consumers that
|
|
443
|
+
// destructure it.
|
|
444
|
+
filesTombstoned: 0,
|
|
410
445
|
};
|
|
411
446
|
}
|
|
412
447
|
if (resolution === "keep" || resolution === "skip") {
|
|
@@ -526,6 +561,108 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
526
561
|
}
|
|
527
562
|
emit({ type: "new-files", files: enrichedNewFiles });
|
|
528
563
|
|
|
564
|
+
// Codex P1 (PR #24 follow-up): scope-gate tombstone candidates with
|
|
565
|
+
// a per-object HEAD before unlinking. `listRemoteFiles` is STS-scoped
|
|
566
|
+
// (guest sessions with `allowedPrefixes`, role downgrade, custom
|
|
567
|
+
// sync-mode prefix sets — see the AccessDenied branch in the download
|
|
568
|
+
// loop above), so a journal entry's absence from LIST does not prove
|
|
569
|
+
// the object was deleted; it may simply be invisible to this session.
|
|
570
|
+
// HEAD each candidate:
|
|
571
|
+
// - HEAD returns metadata → object exists → NOT in our LIST scope →
|
|
572
|
+
// skip the tombstone (peer didn't delete it; we just can't see it).
|
|
573
|
+
// - HEAD returns null (NotFound) → confirmed deleted → tombstone.
|
|
574
|
+
// - HEAD throws AccessDenied → can't tell → defensive skip; journal
|
|
575
|
+
// stays so next sync (with broader scope) can re-evaluate.
|
|
576
|
+
// - HEAD throws transient → defensive skip + emit error.
|
|
577
|
+
// Bounded concurrency mirrors the new-files attribution pass above.
|
|
578
|
+
if (plan.tombstones.length > 0) {
|
|
579
|
+
const HEAD_VERIFY_CONCURRENCY = 5;
|
|
580
|
+
const verified: string[] = [];
|
|
581
|
+
for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
|
|
582
|
+
const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
|
|
583
|
+
const results = await Promise.all(
|
|
584
|
+
batch.map(async (key) => {
|
|
585
|
+
try {
|
|
586
|
+
const head = await headRemoteFile(ctx, key);
|
|
587
|
+
return head === null ? key : null;
|
|
588
|
+
} catch (err) {
|
|
589
|
+
if (isAccessDenied(err)) return null;
|
|
590
|
+
emit({
|
|
591
|
+
type: "error",
|
|
592
|
+
path: key,
|
|
593
|
+
message: `tombstone HEAD verify failed (deferring): ${
|
|
594
|
+
err instanceof Error ? err.message : String(err)
|
|
595
|
+
}`,
|
|
596
|
+
});
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
}),
|
|
600
|
+
);
|
|
601
|
+
for (const k of results) {
|
|
602
|
+
if (k !== null) verified.push(k);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
plan.tombstones = verified;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Bug #9 — apply cross-machine delete propagation. Each tombstone is a
|
|
609
|
+
// key the journal records as previously synced but the remote LIST no
|
|
610
|
+
// longer contains. We delete the local file (or symlink, or empty dir
|
|
611
|
+
// remnant) and drop the journal entry so the next sync's planner stays
|
|
612
|
+
// converged. Failures are reported but non-fatal — the entry stays in
|
|
613
|
+
// the journal and the next run retries.
|
|
614
|
+
for (const key of plan.tombstones) {
|
|
615
|
+
const localPath = path.join(companyRoot, key);
|
|
616
|
+
let removedSomething = false;
|
|
617
|
+
try {
|
|
618
|
+
const lstat = fs.lstatSync(localPath);
|
|
619
|
+
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
620
|
+
fs.unlinkSync(localPath);
|
|
621
|
+
removedSomething = true;
|
|
622
|
+
} else if (lstat.isDirectory()) {
|
|
623
|
+
// A dir at a key — likely from a (local-dir, cloud-file) historic
|
|
624
|
+
// state. Don't recursively rm-rf the operator's dir; just drop
|
|
625
|
+
// the journal entry so we converge with reality.
|
|
626
|
+
}
|
|
627
|
+
} catch (err: unknown) {
|
|
628
|
+
const code =
|
|
629
|
+
err && typeof err === "object" && "code" in err
|
|
630
|
+
? (err as { code?: string }).code
|
|
631
|
+
: undefined;
|
|
632
|
+
// ENOENT → local already gone; safe to drop the journal entry.
|
|
633
|
+
// Other errors (EACCES/EPERM/EBUSY/etc.) leave the local file in
|
|
634
|
+
// place — if we dropped the journal entry anyway, the pull side
|
|
635
|
+
// would forget the peer's delete and a later push could re-upload
|
|
636
|
+
// the still-present local file, silently undoing the peer's delete.
|
|
637
|
+
// Surface the error and KEEP the journal entry so the next sync
|
|
638
|
+
// retries the unlink after the operator fixes the permission.
|
|
639
|
+
if (code !== "ENOENT") {
|
|
640
|
+
emit({
|
|
641
|
+
type: "error",
|
|
642
|
+
path: key,
|
|
643
|
+
message: `tombstone unlink failed: ${
|
|
644
|
+
err instanceof Error ? err.message : String(err)
|
|
645
|
+
}`,
|
|
646
|
+
});
|
|
647
|
+
// Skip removeEntry / filesTombstoned / progress event — the
|
|
648
|
+
// tombstone hasn't actually been honored. Next sync retries.
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
removeEntry(journal, key);
|
|
653
|
+
filesTombstoned++;
|
|
654
|
+
emit({
|
|
655
|
+
type: "progress",
|
|
656
|
+
path: key,
|
|
657
|
+
bytes: 0,
|
|
658
|
+
deleted: true,
|
|
659
|
+
// Suffix differentiates a tombstone from a normal delete in the
|
|
660
|
+
// tty stream — matches the push-side `defaultConsoleLogger`
|
|
661
|
+
// tombstone surface in share.ts.
|
|
662
|
+
message: removedSomething ? "tombstone (cross-machine delete)" : "tombstone (already absent locally)",
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
529
666
|
// Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
|
|
530
667
|
// ticks even when nothing transferred. updateEntry only fires on actual
|
|
531
668
|
// downloads; without this, a no-op sync leaves lastSync at the time of the
|
|
@@ -542,6 +679,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
542
679
|
aborted: false,
|
|
543
680
|
newFiles: plan.newFiles,
|
|
544
681
|
newFilesCount: plan.newFilesCount,
|
|
682
|
+
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
683
|
+
filesTombstoned,
|
|
545
684
|
};
|
|
546
685
|
}
|
|
547
686
|
|
|
@@ -590,6 +729,10 @@ type PullPlanItem =
|
|
|
590
729
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
591
730
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
592
731
|
| { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
|
|
732
|
+
// Remote keys refused by ephemeral-mirror policy. The push walker has
|
|
733
|
+
// refused to upload these since 5.33.0; the pull walker now refuses to
|
|
734
|
+
// download them so legacy litter in cloud staging drains naturally.
|
|
735
|
+
| { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string }
|
|
593
736
|
| {
|
|
594
737
|
action: "conflict";
|
|
595
738
|
remoteFile: RemoteFile;
|
|
@@ -622,6 +765,15 @@ interface PullPlan {
|
|
|
622
765
|
/** Files classified as new (no local counterpart at classification time). */
|
|
623
766
|
newFiles: Array<{ path: string; bytes: number }>;
|
|
624
767
|
newFilesCount: number;
|
|
768
|
+
/** Count of remote keys refused by ephemeral-mirror policy. */
|
|
769
|
+
filesExcludedByPolicy: number;
|
|
770
|
+
/**
|
|
771
|
+
* Journal-known keys missing from the remote LIST. The executor will
|
|
772
|
+
* apply each as a local delete (file or symlink) + journal removal,
|
|
773
|
+
* propagating the peer's push-side delete cross-machine (Bug #9).
|
|
774
|
+
* Carried on the plan so the executor can iterate without re-walking.
|
|
775
|
+
*/
|
|
776
|
+
tombstones: string[];
|
|
625
777
|
}
|
|
626
778
|
|
|
627
779
|
/**
|
|
@@ -649,6 +801,17 @@ function computePullPlan(
|
|
|
649
801
|
for (const remoteFile of remoteFiles) {
|
|
650
802
|
const localPath = path.join(companyRoot, remoteFile.key);
|
|
651
803
|
|
|
804
|
+
// Ephemeral-mirror filter — symmetric with the push-side walker. Bug #2
|
|
805
|
+
// in the 5.33.0 deep-test: the push side has refused to upload conflict
|
|
806
|
+
// mirrors since 5.33.0, but the pull side downloaded them freely from
|
|
807
|
+
// staging, so legacy `.conflict-*` litter rode every sync into clean
|
|
808
|
+
// trees. Refuse them here; the V2 verification test (direct S3 inject
|
|
809
|
+
// bypassing the push filter entirely) is the regression contract.
|
|
810
|
+
if (isEphemeralPath(remoteFile.key)) {
|
|
811
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
|
|
652
815
|
if (personalMode && remoteFile.key.startsWith("companies/")) {
|
|
653
816
|
// Default: drop every `companies/...` key — the legacy contract
|
|
654
817
|
// is that the personal bucket should never contain them.
|
|
@@ -705,21 +868,47 @@ function computePullPlan(
|
|
|
705
868
|
// dangling link on every run.
|
|
706
869
|
// 3. A regular file: indistinguishable from current behaviour.
|
|
707
870
|
let localLstat: fs.Stats | null = null;
|
|
871
|
+
let localPathBlockedByFileAncestor = false;
|
|
708
872
|
try {
|
|
709
873
|
localLstat = fs.lstatSync(localPath);
|
|
710
874
|
} catch (err: unknown) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
)
|
|
875
|
+
const code =
|
|
876
|
+
err && typeof err === "object" && "code" in err
|
|
877
|
+
? (err as { code?: string }).code
|
|
878
|
+
: undefined;
|
|
879
|
+
// ENOENT → truly absent → treated as "new file from cloud".
|
|
880
|
+
// ENOTDIR → an ancestor of localPath is a regular file (cloud has a
|
|
881
|
+
// dir at that key, local has a file). Pre-fix this threw
|
|
882
|
+
// and aborted the whole company sync (Bug #10 in the
|
|
883
|
+
// 5.33.0 deep-test verification — \`v4-dir-vs-file\` repro
|
|
884
|
+
// wedged the personal company at \"errored\" status,
|
|
885
|
+
// skipping every later file). Recover by classifying as
|
|
886
|
+
// a structural collision the operator must resolve
|
|
887
|
+
// manually, and let the rest of the company process.
|
|
888
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
718
889
|
throw err;
|
|
719
890
|
}
|
|
891
|
+
if (code === "ENOTDIR") {
|
|
892
|
+
localPathBlockedByFileAncestor = true;
|
|
893
|
+
}
|
|
720
894
|
}
|
|
721
895
|
const localExists = localLstat !== null;
|
|
722
896
|
|
|
897
|
+
if (localPathBlockedByFileAncestor) {
|
|
898
|
+
// Symmetric counterpart to the (local-dir, cloud-file) warning below.
|
|
899
|
+
// Emit the same "manual reconciliation required" surface so the
|
|
900
|
+
// operator sees one consistent message for both topologies; record as
|
|
901
|
+
// skip-local-only so the file is not silently dropped from the
|
|
902
|
+
// count and the executor never tries to lstat/write it.
|
|
903
|
+
console.error(
|
|
904
|
+
` Warning: an ancestor of ${remoteFile.key} exists locally as a regular file; ` +
|
|
905
|
+
`cloud has a deeper key under that path. Skipping; manual ` +
|
|
906
|
+
`reconciliation required (rm the conflicting local file to pull).`,
|
|
907
|
+
);
|
|
908
|
+
items.push({ action: "skip-local-only", remoteFile, localPath });
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
|
|
723
912
|
if (localExists) {
|
|
724
913
|
const isLocalSymlink = localLstat!.isSymbolicLink();
|
|
725
914
|
// Kind-mismatch guard: a remote LIST entry is always a single
|
|
@@ -788,6 +977,7 @@ function computePullPlan(
|
|
|
788
977
|
let bytesToDownload = 0;
|
|
789
978
|
let filesToSkip = 0;
|
|
790
979
|
let filesToConflict = 0;
|
|
980
|
+
let filesExcludedByPolicy = 0;
|
|
791
981
|
const newFiles: Array<{ path: string; bytes: number }> = [];
|
|
792
982
|
for (const item of items) {
|
|
793
983
|
if (item.action === "download") {
|
|
@@ -798,11 +988,106 @@ function computePullPlan(
|
|
|
798
988
|
}
|
|
799
989
|
} else if (item.action === "conflict") {
|
|
800
990
|
filesToConflict++;
|
|
991
|
+
} else if (item.action === "skip-excluded-policy") {
|
|
992
|
+
filesExcludedByPolicy++;
|
|
993
|
+
// Excluded-policy items don't roll into filesToSkip — they're a
|
|
994
|
+
// distinct class surfaced via filesExcludedByPolicy so consumers
|
|
995
|
+
// can render a "N refused by policy" line independently of the
|
|
996
|
+
// generic "N unchanged" tally.
|
|
801
997
|
} else {
|
|
802
998
|
filesToSkip++;
|
|
803
999
|
}
|
|
804
1000
|
}
|
|
805
1001
|
|
|
1002
|
+
// Bug #9 — cross-machine delete propagation. The peer's push leg
|
|
1003
|
+
// already removed the file from S3 (verified in the deep-test
|
|
1004
|
+
// addendum: `aws s3 ls` showed the keys absent post-push). Pre-fix
|
|
1005
|
+
// the pull side enumerated remote keys and downloaded anything
|
|
1006
|
+
// missing locally, but never enumerated "what's missing-from-remote-
|
|
1007
|
+
// that-was-there-before" — so the file stayed forever on every
|
|
1008
|
+
// receiver and `filesTombstoned: 0` showed up on every pull.
|
|
1009
|
+
//
|
|
1010
|
+
// Closing the loop: walk the journal, find every entry whose key is
|
|
1011
|
+
// NOT in the remote LIST set, AND whose path passes the current
|
|
1012
|
+
// ignore filter (paths newly excluded by .hqignore must not trigger
|
|
1013
|
+
// mass-delete), AND — in personalMode — survives the same companies/*
|
|
1014
|
+
// gating the download branch applies. The executor will apply each
|
|
1015
|
+
// as a local delete + journal removal. Symmetric to the push side's
|
|
1016
|
+
// `propagateDeletes` plan in share.ts.
|
|
1017
|
+
const remoteKeySet = new Set<string>();
|
|
1018
|
+
for (const rf of remoteFiles) remoteKeySet.add(rf.key);
|
|
1019
|
+
const tombstones: string[] = [];
|
|
1020
|
+
for (const key of Object.keys(journal.files)) {
|
|
1021
|
+
if (remoteKeySet.has(key)) continue;
|
|
1022
|
+
// PersonalMode key gating — mirror the download branch.
|
|
1023
|
+
if (personalMode && key.startsWith("companies/")) {
|
|
1024
|
+
const slug = key.split("/")[1] ?? "";
|
|
1025
|
+
const isTeamSyncedOrphan =
|
|
1026
|
+
teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
|
|
1027
|
+
if (!includeLocalCompanies || isTeamSyncedOrphan) continue;
|
|
1028
|
+
}
|
|
1029
|
+
// Ephemeral keys are filtered both directions; never tombstone-
|
|
1030
|
+
// propagate a conflict-mirror.
|
|
1031
|
+
if (isEphemeralPath(key)) continue;
|
|
1032
|
+
// Honor the current ignore filter — if a path was previously synced
|
|
1033
|
+
// but is now ignored (operator edited .hqignore), do NOT delete
|
|
1034
|
+
// the local copy. They're keeping it deliberately.
|
|
1035
|
+
const localPath = path.join(companyRoot, key);
|
|
1036
|
+
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
1037
|
+
// Codex P1 (PR #24 round 3): detect local edits before tombstoning.
|
|
1038
|
+
// Delete-vs-local-edit race: peer deleted the file remotely while
|
|
1039
|
+
// this machine edited it locally before the next sync. Without
|
|
1040
|
+
// this check, the tombstone executor would unlink the locally-
|
|
1041
|
+
// edited file and drop the journal entry, silently destroying the
|
|
1042
|
+
// user's unsynced work. Compare local hash against the journal
|
|
1043
|
+
// baseline; if they diverge, defer the tombstone. The next pull
|
|
1044
|
+
// run after the operator resolves (re-pushes / overwrites) the
|
|
1045
|
+
// local state will re-evaluate.
|
|
1046
|
+
try {
|
|
1047
|
+
const lstat = fs.lstatSync(localPath);
|
|
1048
|
+
if (lstat.isFile()) {
|
|
1049
|
+
const localHash = hashFile(localPath);
|
|
1050
|
+
const journalEntry = journal.files[key];
|
|
1051
|
+
if (journalEntry?.hash && journalEntry.hash !== localHash) {
|
|
1052
|
+
// Local has unsynced edits — defer this tombstone.
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
} else if (lstat.isSymbolicLink()) {
|
|
1056
|
+
// Codex P1 (PR #24 round 4): symlinks have target strings
|
|
1057
|
+
// that can be locally edited too. Pre-fix, `isFile()` was
|
|
1058
|
+
// false for symlinks so the divergence guard skipped them,
|
|
1059
|
+
// and a locally-edited symlink (`ln -sfn new-target old-link`
|
|
1060
|
+
// before the peer's remote-delete) was unlinked silently —
|
|
1061
|
+
// the exact race the guard is meant to prevent. Compare the
|
|
1062
|
+
// readlink hash against the journal baseline; defer on
|
|
1063
|
+
// divergence. readlink errors (race / EACCES) also defer.
|
|
1064
|
+
try {
|
|
1065
|
+
const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
|
|
1066
|
+
const journalEntry = journal.files[key];
|
|
1067
|
+
if (journalEntry?.hash && journalEntry.hash !== localHash) {
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
} catch {
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// Directories: no hash comparison. The tombstone executor's
|
|
1075
|
+
// dir branch doesn't recursively rm-rf — it just drops the
|
|
1076
|
+
// journal entry, which is the safe-by-default behavior.
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
const code =
|
|
1079
|
+
err && typeof err === "object" && "code" in err
|
|
1080
|
+
? (err as { code?: string }).code
|
|
1081
|
+
: undefined;
|
|
1082
|
+
// ENOENT → local already gone; safe to drop journal entry via
|
|
1083
|
+
// the executor's tombstone path.
|
|
1084
|
+
// Other lstat errors (EACCES on parent dir, etc.) → defer:
|
|
1085
|
+
// we can't read local state, so we can't safely decide.
|
|
1086
|
+
if (code !== "ENOENT") continue;
|
|
1087
|
+
}
|
|
1088
|
+
tombstones.push(key);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
806
1091
|
return {
|
|
807
1092
|
items,
|
|
808
1093
|
filesToDownload,
|
|
@@ -811,6 +1096,8 @@ function computePullPlan(
|
|
|
811
1096
|
filesToConflict,
|
|
812
1097
|
newFiles,
|
|
813
1098
|
newFilesCount: newFiles.length,
|
|
1099
|
+
filesExcludedByPolicy,
|
|
1100
|
+
tombstones,
|
|
814
1101
|
};
|
|
815
1102
|
}
|
|
816
1103
|
|
package/src/ignore.test.ts
CHANGED
|
@@ -83,7 +83,7 @@ describe("createIgnoreFilter", () => {
|
|
|
83
83
|
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
it("permissive mode: .hq-* internal state is ignored, .hqignore family
|
|
86
|
+
it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
|
|
87
87
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
88
88
|
// Internal state files that must never round-trip through the bucket.
|
|
89
89
|
expect(shouldSync(path.join(hqRoot, ".hq-sync.pid"))).toBe(false);
|
|
@@ -92,11 +92,65 @@ describe("createIgnoreFilter", () => {
|
|
|
92
92
|
expect(shouldSync(path.join(hqRoot, ".hq-embeddings-pending.json"))).toBe(false);
|
|
93
93
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq-foo.json"))).toBe(false);
|
|
94
94
|
expect(shouldSync(path.join(hqRoot, ".hq-cache/blob.bin"))).toBe(false);
|
|
95
|
-
// Sync-config files
|
|
95
|
+
// Sync-config files still sync (no hyphen, doesn't match `.hq-*`).
|
|
96
96
|
expect(shouldSync(path.join(hqRoot, ".hqignore"))).toBe(true);
|
|
97
97
|
expect(shouldSync(path.join(hqRoot, ".hqsyncignore"))).toBe(true);
|
|
98
98
|
expect(shouldSync(path.join(hqRoot, ".hqinclude"))).toBe(true);
|
|
99
|
-
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("permissive mode: .claude/state/ and .claude/audit/ are per-host, never synced", () => {
|
|
102
|
+
// 5.34.0 live cross-machine sync test (macOS ↔ Lightsail) wrote
|
|
103
|
+
// ~25 of the 30 conflict mirrors directly to these paths. They're
|
|
104
|
+
// session-/host-scoped by design:
|
|
105
|
+
// - .claude/state/active-session-project: the active project
|
|
106
|
+
// pointer for the currently-foregrounded Claude Code session.
|
|
107
|
+
// - .claude/state/auto-session-project-<sessionUuid>: one
|
|
108
|
+
// marker per spawned session; entirely host-local.
|
|
109
|
+
// - .claude/audit/: hook-decision audit log + suppressions
|
|
110
|
+
// config; host-specific by nature.
|
|
111
|
+
// Syncing produces guaranteed conflicts per machine per session.
|
|
112
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
113
|
+
expect(shouldSync(path.join(hqRoot, ".claude/state/active-session-project"))).toBe(false);
|
|
114
|
+
expect(
|
|
115
|
+
shouldSync(
|
|
116
|
+
path.join(hqRoot, ".claude/state/auto-session-project-571177ff-59bb-4728-8670-0bf66a6410a5"),
|
|
117
|
+
),
|
|
118
|
+
).toBe(false);
|
|
119
|
+
expect(shouldSync(path.join(hqRoot, ".claude/audit/instructions.md"))).toBe(false);
|
|
120
|
+
expect(shouldSync(path.join(hqRoot, ".claude/audit/suppressions.yaml"))).toBe(false);
|
|
121
|
+
expect(shouldSync(path.join(hqRoot, ".claude/state"), true)).toBe(false);
|
|
122
|
+
expect(shouldSync(path.join(hqRoot, ".claude/audit"), true)).toBe(false);
|
|
123
|
+
// Nested .claude/ inside a company also stays local — same anchor
|
|
124
|
+
// shape as the .claude/worktrees/ rule directly above.
|
|
125
|
+
expect(
|
|
126
|
+
shouldSync(path.join(hqRoot, "companies/indigo/.claude/state/active-session-project")),
|
|
127
|
+
).toBe(false);
|
|
128
|
+
expect(
|
|
129
|
+
shouldSync(path.join(hqRoot, "companies/indigo/.claude/audit/instructions.md")),
|
|
130
|
+
).toBe(false);
|
|
131
|
+
// The .claude/ dir's other contents (settings, commands, skills,
|
|
132
|
+
// hooks) still sync — the rule MUST NOT broaden to .claude/.
|
|
133
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
|
|
134
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
135
|
+
expect(shouldSync(path.join(hqRoot, ".claude/hooks/foo.sh"))).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("permissive mode: .hq/ directory is per-host state, never synced (Bug #1/#6/#8)", () => {
|
|
139
|
+
// Bug catalog: `.hq/install-manifest.json`, `.hq/machine-id`, and
|
|
140
|
+
// `.hq/machine.json` are per-host source-of-truth files. The original
|
|
141
|
+
// `DEFAULT_IGNORES` only covered the `.hq-*` wildcard (with hyphen) and
|
|
142
|
+
// explicitly left the `.hq/` directory unaffected — so the install
|
|
143
|
+
// manifest and machine-id round-tripped through S3 between hosts,
|
|
144
|
+
// breaking the per-machine identity contract that the 5.33.0
|
|
145
|
+
// machine-id fix relies on.
|
|
146
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
147
|
+
expect(shouldSync(path.join(hqRoot, ".hq/install-manifest.json"))).toBe(false);
|
|
148
|
+
expect(shouldSync(path.join(hqRoot, ".hq/machine-id"))).toBe(false);
|
|
149
|
+
expect(shouldSync(path.join(hqRoot, ".hq/machine.json"))).toBe(false);
|
|
150
|
+
expect(shouldSync(path.join(hqRoot, ".hq/config.json"))).toBe(false);
|
|
151
|
+
expect(shouldSync(path.join(hqRoot, ".hq"), true)).toBe(false);
|
|
152
|
+
// Nested .hq/ inside a company also stays local (per-host scoping).
|
|
153
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq/config.json"))).toBe(false);
|
|
100
154
|
});
|
|
101
155
|
|
|
102
156
|
it("allowlist mode: presence of .hqinclude switches to opt-in", () => {
|
package/src/ignore.ts
CHANGED
|
@@ -71,9 +71,15 @@ export const DEFAULT_IGNORES = [
|
|
|
71
71
|
// covers `.hq-sync.pid`, `.hq-sync-journal.json`, `.hq-sync-state.json`,
|
|
72
72
|
// `.hq-embeddings-pending.json`, and any future internal-state file. The
|
|
73
73
|
// `.hqignore` / `.hqsyncignore` / `.hqinclude` config files don't match
|
|
74
|
-
// (no hyphen)
|
|
74
|
+
// (no hyphen). The `.hq/` directory holds per-host source-of-truth files
|
|
75
|
+
// (`install-manifest.json`, `machine-id`, `machine.json`, `config.json`)
|
|
76
|
+
// that MUST NEVER round-trip through the bucket — see Bug #1/#6/#8 in
|
|
77
|
+
// the 5.33.0 deep-test report. Leaking these between machines makes two
|
|
78
|
+
// hosts share an installer id / machine-id and breaks the contract the
|
|
79
|
+
// 5.33.0 machine-id fix relies on.
|
|
75
80
|
"*.pid",
|
|
76
81
|
".hq-*",
|
|
82
|
+
".hq/",
|
|
77
83
|
"modules.lock",
|
|
78
84
|
// hq-root identity marker — discovered locally per-machine, never synced.
|
|
79
85
|
// Root-anchored: only the literal `core.yaml` at hq-root matches. Without
|
|
@@ -92,6 +98,20 @@ export const DEFAULT_IGNORES = [
|
|
|
92
98
|
// Claude Code worktrees — local-only working copies, never synced.
|
|
93
99
|
"**/.claude/worktrees/",
|
|
94
100
|
|
|
101
|
+
// Claude Code per-machine session state — every active session writes
|
|
102
|
+
// to `.claude/state/active-session-project` and
|
|
103
|
+
// `.claude/state/auto-session-project-<sessionUuid>` independently on
|
|
104
|
+
// each host. Syncing them produces a guaranteed conflict per machine
|
|
105
|
+
// per session (~25 of the 30 mirrors written during the 5.34.0 live-
|
|
106
|
+
// sync test traced here). The `.claude/audit/` siblings are also
|
|
107
|
+
// per-host (audit log of hook decisions, suppressions, instructions).
|
|
108
|
+
// Both classes are session-/host-scoped by design and must never
|
|
109
|
+
// round-trip. Anchored under `**/.claude/` so the same rule covers
|
|
110
|
+
// hqRoot, company sub-dirs, and embedded knowledge repos uniformly —
|
|
111
|
+
// mirrors the `**/.claude/worktrees/` pattern just above.
|
|
112
|
+
"**/.claude/state/",
|
|
113
|
+
"**/.claude/audit/",
|
|
114
|
+
|
|
95
115
|
// HQ repos directory (managed separately, not synced)
|
|
96
116
|
"repos/",
|
|
97
117
|
|