@indigoai-us/hq-cloud 6.14.36 → 6.14.39
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-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +20 -3
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +46 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +1 -9
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/share.d.ts +178 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +555 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +780 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +27 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +113 -13
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +188 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/readlink-safe.d.ts +11 -0
- package/dist/lib/readlink-safe.d.ts.map +1 -0
- package/dist/lib/readlink-safe.js +27 -0
- package/dist/lib/readlink-safe.js.map +1 -0
- package/dist/lib/readlink-safe.test.d.ts +2 -0
- package/dist/lib/readlink-safe.test.d.ts.map +1 -0
- package/dist/lib/readlink-safe.test.js +34 -0
- package/dist/lib/readlink-safe.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +19 -3
- package/src/bin/sync-runner.test.ts +54 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/reindex.ts +1 -9
- package/src/cli/share.test.ts +974 -2
- package/src/cli/share.ts +675 -32
- package/src/cli/sync.test.ts +209 -0
- package/src/cli/sync.ts +151 -13
- package/src/lib/readlink-safe.test.ts +43 -0
- package/src/lib/readlink-safe.ts +29 -0
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/src/cli/share.ts
CHANGED
|
@@ -71,6 +71,8 @@ import {
|
|
|
71
71
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
72
72
|
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
73
73
|
import { VaultAuthError } from "../vault-client.js";
|
|
74
|
+
import { describeError } from "../lib/describe-error.js";
|
|
75
|
+
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
74
76
|
|
|
75
77
|
/**
|
|
76
78
|
* Push-side fresh-collision convergence probe.
|
|
@@ -258,6 +260,26 @@ export function isMalformedVaultKey(key: string): boolean {
|
|
|
258
260
|
return key.includes("\\");
|
|
259
261
|
}
|
|
260
262
|
|
|
263
|
+
/**
|
|
264
|
+
* A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
|
|
265
|
+
* PERSONAL vault, where it is handled by the dedicated `personalMode` branch
|
|
266
|
+
* in `computePullPlan` (companies/* content a peer machine pushed into the
|
|
267
|
+
* personal bucket). A COMPANY-scoped vault is already anchored at its company
|
|
268
|
+
* root, so its keys are bucket-relative — a `companies/...` key there is a
|
|
269
|
+
* doubly-scoped corrupt object. The vault-service refuses to presign such a
|
|
270
|
+
* key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
|
|
271
|
+
* never materialize it and the whole company sync wedges at `errored` (runner
|
|
272
|
+
* exit 2) on every run. Verified live 2026-06-16: frogbear's
|
|
273
|
+
* `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
|
|
274
|
+
* was uploaded with a doubled key and broke every sync thereafter. The pull
|
|
275
|
+
* and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
|
|
276
|
+
* with the malformed-(backslash)-key filter above; the bogus objects
|
|
277
|
+
* themselves are cleaned server-side.
|
|
278
|
+
*/
|
|
279
|
+
export function isForbiddenCompanyVaultKey(key: string, personalMode: boolean): boolean {
|
|
280
|
+
return !personalMode && key.startsWith("companies/");
|
|
281
|
+
}
|
|
282
|
+
|
|
261
283
|
/**
|
|
262
284
|
* Test-only export. Kept under a `_testing` namespace so the module's public
|
|
263
285
|
* surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
|
|
@@ -271,6 +293,8 @@ export const _testing = {
|
|
|
271
293
|
EPHEMERAL_PATH_PATTERN,
|
|
272
294
|
wrapFilterWithIgnoreVisibility,
|
|
273
295
|
collectFiles,
|
|
296
|
+
resolveNamedPath,
|
|
297
|
+
isWithinLexicalOrReal,
|
|
274
298
|
};
|
|
275
299
|
|
|
276
300
|
/**
|
|
@@ -678,6 +702,67 @@ export interface ShareOptions {
|
|
|
678
702
|
* the network. See the consult in the Stage-2 classification pass.
|
|
679
703
|
*/
|
|
680
704
|
fileTombstones?: Map<string, CompanyTombstone>;
|
|
705
|
+
/**
|
|
706
|
+
* What to do when a path the caller EXPLICITLY named cannot be shipped —
|
|
707
|
+
* it does not resolve under any base, or it resolves outside the company
|
|
708
|
+
* folder (see `ShareResult.unreachablePaths`).
|
|
709
|
+
*
|
|
710
|
+
* - `"error"` (DEFAULT): a path that EXISTS on disk but resolves outside the
|
|
711
|
+
* company folder throws {@link UnreachablePushPathsError} BEFORE any upload
|
|
712
|
+
* runs, so the push is an atomic no-op and the CLI exits nonzero. This is
|
|
713
|
+
* ask #2 of feedback_a51cb63d — "error, not warn-skip, when the named file
|
|
714
|
+
* exists locally but is unreachable by the resolver". A user who typed a
|
|
715
|
+
* path and got "✓ Pushed 0 file(s)" had no way to know their content never
|
|
716
|
+
* left the machine. A path that resolves to NOTHING under any base is still
|
|
717
|
+
* only warn-recorded (see `collectFatalUnreachablePaths` for why bulk
|
|
718
|
+
* membership fanout depends on that).
|
|
719
|
+
* - `"warn"`: record it on the result + emit the `not-shipped` event and
|
|
720
|
+
* carry on, never throwing. For callers whose `paths` are INTERNAL walk
|
|
721
|
+
* roots rather than user input — the background sync runner — where a path
|
|
722
|
+
* disappearing mid-run is a benign race (a directory removed between the
|
|
723
|
+
* scan and the push) and must never fail an unattended sync.
|
|
724
|
+
*/
|
|
725
|
+
unreachablePathPolicy?: "error" | "warn";
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Why an explicitly-named push path could not be shipped. */
|
|
729
|
+
export type UnreachablePathReason = "missing" | "outside-company" | "unreadable-link";
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Thrown by `share()` when a caller-named path cannot be pushed and
|
|
733
|
+
* `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
|
|
734
|
+
* are still being built, so NOTHING has been uploaded, journaled, or deleted
|
|
735
|
+
* when it surfaces — the failed push leaves no partial state behind.
|
|
736
|
+
*/
|
|
737
|
+
export class UnreachablePushPathsError extends Error {
|
|
738
|
+
/** Caller's original spellings, verbatim (see the CollectHooks contract). */
|
|
739
|
+
readonly paths: string[];
|
|
740
|
+
/** Per-path reason, keyed by the same original spelling. */
|
|
741
|
+
readonly reasons: Record<string, UnreachablePathReason>;
|
|
742
|
+
|
|
743
|
+
constructor(unreachable: ReadonlyMap<string, UnreachablePathReason>, syncRoot: string) {
|
|
744
|
+
const paths = [...unreachable.keys()];
|
|
745
|
+
const lines = paths.map((p) => {
|
|
746
|
+
const reason = unreachable.get(p);
|
|
747
|
+
if (reason === "outside-company") {
|
|
748
|
+
return ` · ${p} — resolves outside the company folder (${syncRoot})`;
|
|
749
|
+
}
|
|
750
|
+
if (reason === "unreadable-link") {
|
|
751
|
+
return ` · ${p} — symbolic link target could not be read; it was not dereferenced or uploaded`;
|
|
752
|
+
}
|
|
753
|
+
return ` · ${p} — not found under the hq root, the company folder, or the current directory`;
|
|
754
|
+
});
|
|
755
|
+
super(
|
|
756
|
+
`${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
|
|
757
|
+
`nothing was uploaded.\n${lines.join("\n")}\n` +
|
|
758
|
+
`A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
|
|
759
|
+
`(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
|
|
760
|
+
`outside HQ has to sync through whatever owns it, not the vault.`,
|
|
761
|
+
);
|
|
762
|
+
this.name = "UnreachablePushPathsError";
|
|
763
|
+
this.paths = paths;
|
|
764
|
+
this.reasons = Object.fromEntries(unreachable);
|
|
765
|
+
}
|
|
681
766
|
}
|
|
682
767
|
|
|
683
768
|
export interface ShareResult {
|
|
@@ -757,6 +842,34 @@ export interface ShareResult {
|
|
|
757
842
|
* once if this is > 0).
|
|
758
843
|
*/
|
|
759
844
|
filesExcludedByIgnore: number;
|
|
845
|
+
/**
|
|
846
|
+
* Paths the caller EXPLICITLY named for push that exist locally but the
|
|
847
|
+
* resolver could not place under the company folder (or could not find under
|
|
848
|
+
* any base). Empty in the common case (internal walk roots are always the
|
|
849
|
+
* reachable company folder). A non-empty list means the push did NOT ship
|
|
850
|
+
* something the caller asked for.
|
|
851
|
+
*
|
|
852
|
+
* Only ever non-empty under `unreachablePathPolicy: "warn"` — the DEFAULT
|
|
853
|
+
* `"error"` policy throws {@link UnreachablePushPathsError} instead of
|
|
854
|
+
* returning, so an interactive `hq sync push` fails loudly rather than
|
|
855
|
+
* reporting the pre-fix silent "Pushed 0 file(s)" success. This field is the
|
|
856
|
+
* warn-mode surface for unattended callers (sync runner / watcher).
|
|
857
|
+
*
|
|
858
|
+
* Entries are the caller's ORIGINAL spellings, verbatim — a relative token
|
|
859
|
+
* stays relative, an absolute token stays absolute — so the list is directly
|
|
860
|
+
* comparable to the `paths` input. See the CollectHooks spelling contract.
|
|
861
|
+
* Mirrors the `not-shipped` event with `reason: "unreachable-path"`.
|
|
862
|
+
*/
|
|
863
|
+
unreachablePaths: string[];
|
|
864
|
+
/**
|
|
865
|
+
* Company-relative keys of directory symlinks that were recorded as links but
|
|
866
|
+
* NOT descended because their target lives outside the company folder — their
|
|
867
|
+
* contents sync via their own repo, not the vault. Surfaced so files created
|
|
868
|
+
* under such a link (e.g. `companies/{co}/knowledge` → a linked repo) no
|
|
869
|
+
* longer vanish from every push bucket without a trace. Mirrors the
|
|
870
|
+
* `not-shipped` event with `reason: "linked-subtree"`.
|
|
871
|
+
*/
|
|
872
|
+
linkedSubtreesNotShipped: string[];
|
|
760
873
|
/**
|
|
761
874
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
762
875
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -935,6 +1048,18 @@ interface PushRunContext {
|
|
|
935
1048
|
scopeExcludedSet: Set<string>;
|
|
936
1049
|
ignoreExcludedSet: Set<string>;
|
|
937
1050
|
ignoreExcludedTotal: { value: number };
|
|
1051
|
+
/** Explicitly-named push paths that exist locally but the resolver could not
|
|
1052
|
+
* place under the company folder (or find at all), keyed by the CALLER'S
|
|
1053
|
+
* ORIGINAL spelling (see the CollectHooks spelling contract) and valued by
|
|
1054
|
+
* why it could not be shipped. Non-empty ⇒ the push did NOT ship something
|
|
1055
|
+
* the caller named — surfaced so a "Pushed 0 file(s)" is never a silent
|
|
1056
|
+
* false success, and (under the default `unreachablePathPolicy: "error"`)
|
|
1057
|
+
* raised as a hard failure before any upload runs. */
|
|
1058
|
+
unreachablePaths: Map<string, UnreachablePathReason>;
|
|
1059
|
+
/** Company-relative keys of directory symlinks recorded but not descended
|
|
1060
|
+
* because their target lives outside the company folder (contents sync via
|
|
1061
|
+
* their own repo, not the vault). */
|
|
1062
|
+
linkedSubtreeSet: Set<string>;
|
|
938
1063
|
}
|
|
939
1064
|
|
|
940
1065
|
interface ShareCounters {
|
|
@@ -1040,6 +1165,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
1040
1165
|
const onScopeExcluded = (rel: string) => {
|
|
1041
1166
|
scopeExcludedSet.add(rel);
|
|
1042
1167
|
};
|
|
1168
|
+
const unreachablePaths = new Map<string, UnreachablePathReason>();
|
|
1169
|
+
const linkedSubtreeSet = new Set<string>();
|
|
1043
1170
|
const baseFilter = options.personalMode === true
|
|
1044
1171
|
? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
|
|
1045
1172
|
: recordedIgnoreFilter;
|
|
@@ -1073,6 +1200,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
1073
1200
|
scopeExcludedSet,
|
|
1074
1201
|
ignoreExcludedSet,
|
|
1075
1202
|
ignoreExcludedTotal,
|
|
1203
|
+
unreachablePaths,
|
|
1204
|
+
linkedSubtreeSet,
|
|
1076
1205
|
};
|
|
1077
1206
|
}
|
|
1078
1207
|
|
|
@@ -1094,7 +1223,30 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1094
1223
|
run.hqRoot,
|
|
1095
1224
|
run.syncRoot,
|
|
1096
1225
|
run.shouldSync,
|
|
1226
|
+
{
|
|
1227
|
+
onUnreachablePath: (namedPath, reason) => {
|
|
1228
|
+
// First reason wins: a path is named once, and re-adding would only
|
|
1229
|
+
// churn the map ordering the error message and event sample rely on.
|
|
1230
|
+
if (!run.unreachablePaths.has(namedPath)) {
|
|
1231
|
+
run.unreachablePaths.set(namedPath, reason);
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
|
|
1235
|
+
},
|
|
1097
1236
|
);
|
|
1237
|
+
// Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
|
|
1238
|
+
// exists locally but is unreachable by the resolver". This is the throw that
|
|
1239
|
+
// makes it true end-to-end — the CLI's push handler already turns a thrown
|
|
1240
|
+
// error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
|
|
1241
|
+
// "✓ Pushed 0 file(s)" success can no longer happen for a file that is
|
|
1242
|
+
// sitting right there on disk. It fires HERE, before executeUploads, so a
|
|
1243
|
+
// failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
|
|
1244
|
+
// written, no delete propagated.
|
|
1245
|
+
const fatal = collectFatalUnreachablePaths(run);
|
|
1246
|
+
if (fatal.size > 0) {
|
|
1247
|
+
emitUnreachablePathEvent(run);
|
|
1248
|
+
throw new UnreachablePushPathsError(fatal, run.syncRoot);
|
|
1249
|
+
}
|
|
1098
1250
|
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
1099
1251
|
// root IS the company folder, so a local entry whose vault key starts with
|
|
1100
1252
|
// `companies/` can only come from a stale doubled local tree
|
|
@@ -1480,8 +1632,7 @@ async function executeUploads(
|
|
|
1480
1632
|
run.emit({
|
|
1481
1633
|
type: "error",
|
|
1482
1634
|
path: relativePath,
|
|
1483
|
-
message:
|
|
1484
|
-
retryErr instanceof Error ? retryErr.message : String(retryErr),
|
|
1635
|
+
message: describeError(retryErr),
|
|
1485
1636
|
});
|
|
1486
1637
|
}
|
|
1487
1638
|
return;
|
|
@@ -1501,7 +1652,7 @@ async function executeUploads(
|
|
|
1501
1652
|
run.emit({
|
|
1502
1653
|
type: "error",
|
|
1503
1654
|
path: relativePath,
|
|
1504
|
-
message:
|
|
1655
|
+
message: describeError(err),
|
|
1505
1656
|
});
|
|
1506
1657
|
}
|
|
1507
1658
|
};
|
|
@@ -1572,9 +1723,7 @@ async function writePushConflictMirror(
|
|
|
1572
1723
|
run.emit({
|
|
1573
1724
|
type: "error",
|
|
1574
1725
|
path: item.relativePath,
|
|
1575
|
-
message:
|
|
1576
|
-
"conflict mirror write failed: " +
|
|
1577
|
-
(mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)),
|
|
1726
|
+
message: "conflict mirror write failed: " + describeError(mirrorErr),
|
|
1578
1727
|
});
|
|
1579
1728
|
}
|
|
1580
1729
|
}
|
|
@@ -1654,7 +1803,7 @@ async function executeDeletes(
|
|
|
1654
1803
|
run.emit({
|
|
1655
1804
|
type: "error",
|
|
1656
1805
|
path: relativePath,
|
|
1657
|
-
message:
|
|
1806
|
+
message: describeError(err),
|
|
1658
1807
|
});
|
|
1659
1808
|
pathResults.push({
|
|
1660
1809
|
path: relativePath,
|
|
@@ -1665,6 +1814,61 @@ async function executeDeletes(
|
|
|
1665
1814
|
}
|
|
1666
1815
|
}
|
|
1667
1816
|
for (const relativePath of deletePlan.toTombstone) {
|
|
1817
|
+
const localPath = localPathForVaultKey(run.syncRoot, relativePath);
|
|
1818
|
+
try {
|
|
1819
|
+
const lstat = fs.lstatSync(localPath);
|
|
1820
|
+
const entry = run.journal.files[relativePath];
|
|
1821
|
+
if (lstat.isFile()) {
|
|
1822
|
+
const localHash = hashFile(localPath);
|
|
1823
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1824
|
+
run.emit({
|
|
1825
|
+
type: "error",
|
|
1826
|
+
path: relativePath,
|
|
1827
|
+
message:
|
|
1828
|
+
"scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1829
|
+
});
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
fs.unlinkSync(localPath);
|
|
1833
|
+
} else if (lstat.isSymbolicLink()) {
|
|
1834
|
+
const target = readlinkOrNull(localPath);
|
|
1835
|
+
if (target === null) {
|
|
1836
|
+
run.emit({
|
|
1837
|
+
type: "not-shipped",
|
|
1838
|
+
reason: "unreadable-link",
|
|
1839
|
+
count: 1,
|
|
1840
|
+
samplePaths: [relativePath],
|
|
1841
|
+
});
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
const localHash = hashSymlinkTarget(target);
|
|
1845
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1846
|
+
run.emit({
|
|
1847
|
+
type: "error",
|
|
1848
|
+
path: relativePath,
|
|
1849
|
+
message:
|
|
1850
|
+
"scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1851
|
+
});
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
fs.unlinkSync(localPath);
|
|
1855
|
+
}
|
|
1856
|
+
} catch (err: unknown) {
|
|
1857
|
+
const code =
|
|
1858
|
+
err && typeof err === "object" && "code" in err
|
|
1859
|
+
? (err as { code?: string }).code
|
|
1860
|
+
: undefined;
|
|
1861
|
+
if (code !== "ENOENT") {
|
|
1862
|
+
run.emit({
|
|
1863
|
+
type: "error",
|
|
1864
|
+
path: relativePath,
|
|
1865
|
+
message: `tombstone unlink failed: ${
|
|
1866
|
+
err instanceof Error ? err.message : String(err)
|
|
1867
|
+
}`,
|
|
1868
|
+
});
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1668
1872
|
removeEntry(run.journal, relativePath);
|
|
1669
1873
|
counters.filesTombstoned++;
|
|
1670
1874
|
run.emit({
|
|
@@ -1748,6 +1952,92 @@ function finalizeShareJournal(run: PushRunContext): void {
|
|
|
1748
1952
|
samplePaths,
|
|
1749
1953
|
});
|
|
1750
1954
|
}
|
|
1955
|
+
|
|
1956
|
+
emitUnreachablePathEvent(run);
|
|
1957
|
+
|
|
1958
|
+
if (run.linkedSubtreeSet.size > 0) {
|
|
1959
|
+
run.emit({
|
|
1960
|
+
type: "not-shipped",
|
|
1961
|
+
reason: "linked-subtree",
|
|
1962
|
+
count: run.linkedSubtreeSet.size,
|
|
1963
|
+
samplePaths: sampleSet(run.linkedSubtreeSet),
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* Which unreachable named paths are FATAL under the run's policy.
|
|
1970
|
+
*
|
|
1971
|
+
* Under the default `"error"` policy only `"outside-company"` is fatal: the
|
|
1972
|
+
* entry is sitting on disk and the resolver refused to place it under the
|
|
1973
|
+
* company folder, which is precisely the "exists locally but is unreachable by
|
|
1974
|
+
* the resolver" case the report asks to turn into an error, and it cannot
|
|
1975
|
+
* happen for an internal walk root (a company folder is trivially inside
|
|
1976
|
+
* itself).
|
|
1977
|
+
*
|
|
1978
|
+
* `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
|
|
1979
|
+
* and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
|
|
1980
|
+
* one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
|
|
1981
|
+
* folder was never materialized locally; throwing there would turn "you haven't
|
|
1982
|
+
* pulled that company yet" into a hard failure of an unrelated multi-company
|
|
1983
|
+
* push. Same reasoning for a watcher path deleted between the event and the
|
|
1984
|
+
* push. Those callers get the loud report without the regression.
|
|
1985
|
+
*
|
|
1986
|
+
* `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
|
|
1987
|
+
* end to end (the background sync runner).
|
|
1988
|
+
*/
|
|
1989
|
+
function collectFatalUnreachablePaths(
|
|
1990
|
+
run: PushRunContext,
|
|
1991
|
+
): Map<string, UnreachablePathReason> {
|
|
1992
|
+
const fatal = new Map<string, UnreachablePathReason>();
|
|
1993
|
+
if (run.options.unreachablePathPolicy === "warn") return fatal;
|
|
1994
|
+
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
1995
|
+
if (reason === "outside-company") fatal.set(namedPath, reason);
|
|
1996
|
+
}
|
|
1997
|
+
return fatal;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Emit the `not-shipped` / `unreachable-path` event for a run, if any named
|
|
2002
|
+
* path went unshipped. Shared by BOTH policies so the operator sees the same
|
|
2003
|
+
* report either way: the fail-fast path emits it immediately before throwing
|
|
2004
|
+
* (the journal finalizer never runs on a throw), and the `"warn"` path emits it
|
|
2005
|
+
* from the finalizer at the end of a successful run. Exactly one of those two
|
|
2006
|
+
* call sites can fire per run, so the event is never duplicated.
|
|
2007
|
+
*/
|
|
2008
|
+
function emitUnreachablePathEvent(run: PushRunContext): void {
|
|
2009
|
+
if (run.unreachablePaths.size === 0) return;
|
|
2010
|
+
const unreadableLinks: string[] = [];
|
|
2011
|
+
const unreachablePaths: string[] = [];
|
|
2012
|
+
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
2013
|
+
(reason === "unreadable-link" ? unreadableLinks : unreachablePaths).push(namedPath);
|
|
2014
|
+
}
|
|
2015
|
+
if (unreachablePaths.length > 0) {
|
|
2016
|
+
run.emit({
|
|
2017
|
+
type: "not-shipped",
|
|
2018
|
+
reason: "unreachable-path",
|
|
2019
|
+
count: unreachablePaths.length,
|
|
2020
|
+
samplePaths: unreachablePaths.slice(0, 10),
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
if (unreadableLinks.length > 0) {
|
|
2024
|
+
run.emit({
|
|
2025
|
+
type: "not-shipped",
|
|
2026
|
+
reason: "unreadable-link",
|
|
2027
|
+
count: unreadableLinks.length,
|
|
2028
|
+
samplePaths: unreadableLinks.slice(0, 10),
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/** First up-to-`limit` members of an iterable, for bounded event payloads. */
|
|
2034
|
+
function sampleSet(set: Iterable<string>, limit = 10): string[] {
|
|
2035
|
+
const sample: string[] = [];
|
|
2036
|
+
for (const value of set) {
|
|
2037
|
+
sample.push(value);
|
|
2038
|
+
if (sample.length >= limit) break;
|
|
2039
|
+
}
|
|
2040
|
+
return sample;
|
|
1751
2041
|
}
|
|
1752
2042
|
|
|
1753
2043
|
function throwUploadWorkerErrors(workerErrors: Error[]): void {
|
|
@@ -1784,6 +2074,8 @@ function buildShareResult(
|
|
|
1784
2074
|
filesExcludedByPolicy: run.excludedSet.size,
|
|
1785
2075
|
filesExcludedByScope: run.scopeExcludedSet.size,
|
|
1786
2076
|
filesExcludedByIgnore: run.ignoreExcludedSet.size,
|
|
2077
|
+
unreachablePaths: [...run.unreachablePaths.keys()],
|
|
2078
|
+
linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
|
|
1787
2079
|
conflictPaths,
|
|
1788
2080
|
pathResults,
|
|
1789
2081
|
aborted,
|
|
@@ -1864,6 +2156,28 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
1864
2156
|
if (event.count > event.samplePaths.length) {
|
|
1865
2157
|
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1866
2158
|
}
|
|
2159
|
+
} else if (event.type === "not-shipped") {
|
|
2160
|
+
// The other "not silent" surface: content the walk saw but chose not to
|
|
2161
|
+
// ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
|
|
2162
|
+
if (event.reason === "unreachable-path") {
|
|
2163
|
+
console.warn(
|
|
2164
|
+
` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
|
|
2165
|
+
);
|
|
2166
|
+
} else if (event.reason === "unreadable-link") {
|
|
2167
|
+
console.warn(
|
|
2168
|
+
` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
|
|
2169
|
+
);
|
|
2170
|
+
} else {
|
|
2171
|
+
console.warn(
|
|
2172
|
+
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
for (const p of event.samplePaths) {
|
|
2176
|
+
console.warn(` · ${p}`);
|
|
2177
|
+
}
|
|
2178
|
+
if (event.count > event.samplePaths.length) {
|
|
2179
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
2180
|
+
}
|
|
1867
2181
|
}
|
|
1868
2182
|
}
|
|
1869
2183
|
|
|
@@ -1881,6 +2195,284 @@ type CollectedEntry =
|
|
|
1881
2195
|
| { kind: "file"; absolutePath: string; relativePath: string }
|
|
1882
2196
|
| { kind: "symlink"; absolutePath: string; relativePath: string; target: string };
|
|
1883
2197
|
|
|
2198
|
+
/**
|
|
2199
|
+
* Optional visibility callbacks for {@link collectFiles} / {@link walkDir}.
|
|
2200
|
+
* They turn two previously-SILENT outcomes into surfaced signals — without
|
|
2201
|
+
* changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
|
|
2202
|
+
*
|
|
2203
|
+
* - `onUnreachablePath`: a path that cannot be shipped. It is normally the
|
|
2204
|
+
* caller's explicit spelling (`"outside-company"` or `"missing"`), and is
|
|
2205
|
+
* the company-relative key for an unreadable link discovered during a
|
|
2206
|
+
* directory walk (`"unreadable-link"`). Pre-fix this was a bare
|
|
2207
|
+
* `console.error` warn-skip that still let the push report "Pushed 0 file(s)"
|
|
2208
|
+
* — a false success. The caller can now turn a non-empty set into a real
|
|
2209
|
+
* error / nonzero exit.
|
|
2210
|
+
*
|
|
2211
|
+
* SPELLING CONTRACT: resolver outcomes use the caller's ORIGINAL token,
|
|
2212
|
+
* verbatim — never the resolved absolute path, and never normalized. A
|
|
2213
|
+
* relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
|
|
2214
|
+
* an absolute path is reported absolute because that is what was passed.
|
|
2215
|
+
* An unreadable link discovered during a recursive walk instead uses its
|
|
2216
|
+
* company-relative vault key, because it has no separate caller token.
|
|
2217
|
+
* This keeps UI output actionable without exposing host-specific paths.
|
|
2218
|
+
* - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
|
|
2219
|
+
* descended because its target resolves OUTSIDE the company folder (e.g.
|
|
2220
|
+
* `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
|
|
2221
|
+
* contents ship via their own repo, not the vault; reporting it stops files
|
|
2222
|
+
* created under such a link from vanishing from every push bucket silently.
|
|
2223
|
+
*/
|
|
2224
|
+
interface CollectHooks {
|
|
2225
|
+
onUnreachablePath?: (namedPath: string, reason: UnreachablePathReason) => void;
|
|
2226
|
+
onLinkedSubtree?: (rel: string) => void;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* Resolve a caller-supplied push path to an absolute path.
|
|
2231
|
+
*
|
|
2232
|
+
* Relative paths were historically resolved against `hqRoot` ONLY, so a
|
|
2233
|
+
* company-relative spelling like `knowledge/agents/x.md` became
|
|
2234
|
+
* `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
|
|
2235
|
+
* caller's cwd or the company being pushed (feedback_258e4a86 /
|
|
2236
|
+
* feedback_a51cb63d — "there is no path spelling that reaches the file").
|
|
2237
|
+
*
|
|
2238
|
+
* PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
|
|
2239
|
+
* (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
|
|
2240
|
+
* ADDITIVE to the legacy behavior: every relative spelling that resolved
|
|
2241
|
+
* pre-fix still resolves to exactly the same file, and the two new bases only
|
|
2242
|
+
* catch spellings that previously resolved to nothing. Probing cwd first would
|
|
2243
|
+
* silently re-point existing callers (a `knowledge/` directory in the shell's
|
|
2244
|
+
* cwd would win over the hq-root one), which is a behavior change no reporter
|
|
2245
|
+
* asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
|
|
2246
|
+
* the unchanged "does not exist" diagnostic. Absolute paths are returned
|
|
2247
|
+
* verbatim.
|
|
2248
|
+
*
|
|
2249
|
+
* `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
|
|
2250
|
+
* rather than an ambient read, so resolution is deterministic and testable
|
|
2251
|
+
* without mutating process state.
|
|
2252
|
+
*/
|
|
2253
|
+
function resolveNamedPath(
|
|
2254
|
+
p: string,
|
|
2255
|
+
hqRoot: string,
|
|
2256
|
+
syncRoot: string,
|
|
2257
|
+
cwd: string = process.cwd(),
|
|
2258
|
+
): string {
|
|
2259
|
+
if (path.isAbsolute(p)) return p;
|
|
2260
|
+
const hqRootCandidate = path.resolve(hqRoot, p);
|
|
2261
|
+
const candidates = [
|
|
2262
|
+
hqRootCandidate,
|
|
2263
|
+
path.resolve(syncRoot, p),
|
|
2264
|
+
path.resolve(cwd, p),
|
|
2265
|
+
];
|
|
2266
|
+
for (const candidate of candidates) {
|
|
2267
|
+
try {
|
|
2268
|
+
fs.lstatSync(candidate);
|
|
2269
|
+
// An hqRoot hit outside the company folder would be rejected by
|
|
2270
|
+
// collectFiles as outside-company; skip it so a valid company-relative
|
|
2271
|
+
// spelling can win (Codex P2 — common `knowledge/` homonym case).
|
|
2272
|
+
if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
|
|
2273
|
+
continue;
|
|
2274
|
+
}
|
|
2275
|
+
return candidate;
|
|
2276
|
+
} catch {
|
|
2277
|
+
// Base did not resolve to an on-disk entry — try the next one.
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
return hqRootCandidate;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/**
|
|
2284
|
+
* Containment check for a regular file or directory that tolerates a symlinked
|
|
2285
|
+
* ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
|
|
2286
|
+
* path reached through a symlinked ancestor (`companies/{co}/knowledge` →
|
|
2287
|
+
* `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
|
|
2288
|
+
* rejected as "outside company folder" — even though its logical path is
|
|
2289
|
+
* in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
|
|
2290
|
+
* company-namespaced key for it. Accept when the LEXICAL path is inside
|
|
2291
|
+
* (honoring the same logical topology the vault key uses) OR the realpath is
|
|
2292
|
+
* inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
|
|
2293
|
+
*
|
|
2294
|
+
* The lexical arm carries TWO bounds, because it is the only place where a
|
|
2295
|
+
* path's bytes and its vault key come from different trees:
|
|
2296
|
+
*
|
|
2297
|
+
* 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
|
|
2298
|
+
* symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
|
|
2299
|
+
* state under a company-namespaced key.
|
|
2300
|
+
* 2. The TENANT — the realpath must not land inside another company's bytes
|
|
2301
|
+
* (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
|
|
2302
|
+
* must never be mistaken for a tenant boundary: hqRoot CONTAINS every
|
|
2303
|
+
* other company, so `companies/acme/knowledge → companies/other/secret`
|
|
2304
|
+
* (or → `repos/private/knowledge-other`, the linked-repo topology) is
|
|
2305
|
+
* lexically inside acme and really inside HQ, and would upload the other
|
|
2306
|
+
* tenant's bytes into acme's bucket under the key `knowledge/…`.
|
|
2307
|
+
*
|
|
2308
|
+
* The motivating topology (`companies/{co}/knowledge` →
|
|
2309
|
+
* `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
|
|
2310
|
+
* that escapes HQ, or one that reaches another tenant, is refused — and under
|
|
2311
|
+
* the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
|
|
2312
|
+
*
|
|
2313
|
+
* Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
|
|
2314
|
+
* externally-linked subtree can only be recognized while that company's folder
|
|
2315
|
+
* is materialized locally and publishes the link. A machine holding
|
|
2316
|
+
* `repos/private/knowledge-other` with no `companies/other` folder has no
|
|
2317
|
+
* on-disk evidence of the claim, so a link into it is indistinguishable from a
|
|
2318
|
+
* link into any other local repo. Ownership metadata (not path shape) is what
|
|
2319
|
+
* would close that gap.
|
|
2320
|
+
*/
|
|
2321
|
+
function isWithinLexicalOrReal(
|
|
2322
|
+
parent: string,
|
|
2323
|
+
child: string,
|
|
2324
|
+
hqRoot: string,
|
|
2325
|
+
tenantRootsCache?: Map<string, string[]>,
|
|
2326
|
+
): boolean {
|
|
2327
|
+
// Strict arm first: the realpath is genuinely inside the company folder.
|
|
2328
|
+
// This is the overwhelmingly common case, needs no relaxation, and costs no
|
|
2329
|
+
// directory scan.
|
|
2330
|
+
if (isWithin(parent, child)) return true;
|
|
2331
|
+
|
|
2332
|
+
const resolvedChild = path.resolve(child);
|
|
2333
|
+
if (!isPathWithin(path.resolve(parent), resolvedChild)) return false;
|
|
2334
|
+
|
|
2335
|
+
const childReal = realpathSafe(resolvedChild);
|
|
2336
|
+
if (!isWithin(hqRoot, childReal)) return false; // bound 1: escapes HQ
|
|
2337
|
+
|
|
2338
|
+
// NUL-joined: it is the one byte a path cannot contain, so no pair of
|
|
2339
|
+
// (hqRoot, parent) values can collide on the key.
|
|
2340
|
+
const cacheKey = `${hqRoot}\u0000${parent}`;
|
|
2341
|
+
let foreignRoots = tenantRootsCache?.get(cacheKey);
|
|
2342
|
+
if (foreignRoots === undefined) {
|
|
2343
|
+
foreignRoots = foreignTenantRoots(hqRoot, parent);
|
|
2344
|
+
tenantRootsCache?.set(cacheKey, foreignRoots);
|
|
2345
|
+
}
|
|
2346
|
+
for (const foreign of foreignRoots) {
|
|
2347
|
+
if (isPathWithin(foreign, childReal)) return false; // bound 2: other tenant
|
|
2348
|
+
}
|
|
2349
|
+
return true;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* Depth (in path segments below a company root) at which we look for the
|
|
2354
|
+
* directory symlinks a company publishes into its own folder. HQ's linked
|
|
2355
|
+
* topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
|
|
2356
|
+
* (`companies/{co}/repos/{name}`); going deeper would turn a containment check
|
|
2357
|
+
* into a full-tree walk for no additional coverage.
|
|
2358
|
+
*/
|
|
2359
|
+
const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* Canonicalized roots that belong to a tenant OTHER than the one being pushed.
|
|
2363
|
+
* A lexically-contained path whose realpath lands inside any of these is
|
|
2364
|
+
* another company's data wearing this company's key, and must be refused.
|
|
2365
|
+
*
|
|
2366
|
+
* Two kinds of root are collected per foreign company:
|
|
2367
|
+
* - the company folder itself (`companies/{other}`), and
|
|
2368
|
+
* - the targets of the directory symlinks that folder publishes — HQ's
|
|
2369
|
+
* pattern-2 topology puts a company's knowledge in `repos/private/
|
|
2370
|
+
* knowledge-{other}` and links it in, so the bytes live OUTSIDE every
|
|
2371
|
+
* `companies/` root and a companies-only check would miss them entirely.
|
|
2372
|
+
*
|
|
2373
|
+
* Only consulted on the rare lexical arm (a path whose realpath is not inside
|
|
2374
|
+
* the company folder), never on the ordinary in-tree push, so the directory
|
|
2375
|
+
* scan is not on the hot path. Callers may memoize it for the duration of a
|
|
2376
|
+
* single collect pass; nothing memoizes it for longer, because the sync runner
|
|
2377
|
+
* is long-lived and a stale tenant map fails OPEN — the wrong direction for a
|
|
2378
|
+
* boundary whose whole job is to refuse.
|
|
2379
|
+
*/
|
|
2380
|
+
function foreignTenantRoots(hqRoot: string, syncRoot: string): string[] {
|
|
2381
|
+
const companiesDir = path.join(hqRoot, "companies");
|
|
2382
|
+
let entries: fs.Dirent[];
|
|
2383
|
+
try {
|
|
2384
|
+
entries = fs.readdirSync(companiesDir, { withFileTypes: true });
|
|
2385
|
+
} catch {
|
|
2386
|
+
return []; // no companies/ tree here — nothing to be foreign to
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const activeReal = realpathSafe(syncRoot);
|
|
2390
|
+
const roots: string[] = [];
|
|
2391
|
+
for (const entry of entries) {
|
|
2392
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
2393
|
+
const companyRoot = path.join(companiesDir, entry.name);
|
|
2394
|
+
const companyReal = realpathSafe(companyRoot);
|
|
2395
|
+
if (companyReal === activeReal) continue; // this is the tenant being pushed
|
|
2396
|
+
roots.push(companyReal);
|
|
2397
|
+
collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
|
|
2398
|
+
}
|
|
2399
|
+
return roots;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Append the resolved targets of the directory symlinks published under
|
|
2404
|
+
* `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
|
|
2405
|
+
* inside the company folder are skipped — they add no reach beyond the root
|
|
2406
|
+
* already recorded. Errors are swallowed per entry on purpose: an unreadable
|
|
2407
|
+
* sibling directory must narrow what we can prove, never abort the push it is
|
|
2408
|
+
* unrelated to.
|
|
2409
|
+
*/
|
|
2410
|
+
function collectPublishedLinkTargets(
|
|
2411
|
+
dir: string,
|
|
2412
|
+
companyReal: string,
|
|
2413
|
+
depth: number,
|
|
2414
|
+
out: string[],
|
|
2415
|
+
): void {
|
|
2416
|
+
if (depth <= 0) return;
|
|
2417
|
+
let entries: fs.Dirent[];
|
|
2418
|
+
try {
|
|
2419
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2420
|
+
} catch {
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
for (const entry of entries) {
|
|
2424
|
+
const child = path.join(dir, entry.name);
|
|
2425
|
+
if (entry.isSymbolicLink()) {
|
|
2426
|
+
let real: string;
|
|
2427
|
+
try {
|
|
2428
|
+
real = fs.realpathSync.native(child);
|
|
2429
|
+
} catch {
|
|
2430
|
+
continue; // dangling link claims nothing
|
|
2431
|
+
}
|
|
2432
|
+
try {
|
|
2433
|
+
if (!fs.statSync(real).isDirectory()) continue;
|
|
2434
|
+
} catch {
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
if (isPathWithin(companyReal, real)) continue; // no reach beyond the root
|
|
2438
|
+
out.push(real);
|
|
2439
|
+
} else if (entry.isDirectory()) {
|
|
2440
|
+
collectPublishedLinkTargets(child, companyReal, depth - 1, out);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
|
|
2447
|
+
* `onLinkedSubtree` so the caller can report that the link's contents were not
|
|
2448
|
+
* uploaded to the vault. Fires only for links that (a) resolve to a directory
|
|
2449
|
+
* and (b) point outside the company folder — an in-tree link is descended
|
|
2450
|
+
* elsewhere, and a dangling or file link has nothing behind it to report.
|
|
2451
|
+
*/
|
|
2452
|
+
function reportLinkedSubtreeIfExternal(
|
|
2453
|
+
linkPath: string,
|
|
2454
|
+
syncRoot: string,
|
|
2455
|
+
relativePath: string,
|
|
2456
|
+
hooks: CollectHooks,
|
|
2457
|
+
): void {
|
|
2458
|
+
if (!hooks.onLinkedSubtree) return;
|
|
2459
|
+
let real: string;
|
|
2460
|
+
try {
|
|
2461
|
+
real = fs.realpathSync.native(linkPath); // resolves the link to its target
|
|
2462
|
+
} catch {
|
|
2463
|
+
return; // dangling link — nothing behind it to report
|
|
2464
|
+
}
|
|
2465
|
+
let targetStat: fs.Stats;
|
|
2466
|
+
try {
|
|
2467
|
+
targetStat = fs.statSync(real);
|
|
2468
|
+
} catch {
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
if (!targetStat.isDirectory()) return;
|
|
2472
|
+
if (isWithin(syncRoot, real)) return; // in-tree target: descended elsewhere
|
|
2473
|
+
hooks.onLinkedSubtree(relativePath);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
1884
2476
|
/**
|
|
1885
2477
|
* Collect files from paths (expanding directories recursively).
|
|
1886
2478
|
*
|
|
@@ -1898,11 +2490,17 @@ function collectFiles(
|
|
|
1898
2490
|
hqRoot: string,
|
|
1899
2491
|
syncRoot: string,
|
|
1900
2492
|
filter: (p: string, isDir?: boolean) => boolean,
|
|
2493
|
+
hooks: CollectHooks = {},
|
|
1901
2494
|
): CollectedEntry[] {
|
|
1902
2495
|
const results: CollectedEntry[] = [];
|
|
2496
|
+
// Scoped to THIS collect pass and discarded with it: a watcher batch can
|
|
2497
|
+
// name hundreds of paths, and rescanning the companies/ tree for each one
|
|
2498
|
+
// is wasted I/O. A cache that outlived the pass could fail open on a tenant
|
|
2499
|
+
// boundary, so it deliberately does not.
|
|
2500
|
+
const tenantRoots = new Map<string, string[]>();
|
|
1903
2501
|
|
|
1904
2502
|
for (const p of paths) {
|
|
1905
|
-
const absolutePath =
|
|
2503
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
1906
2504
|
|
|
1907
2505
|
// Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
|
|
1908
2506
|
// Caller may pass one explicitly; we still refuse to upload it. Basename
|
|
@@ -1918,6 +2516,7 @@ function collectFiles(
|
|
|
1918
2516
|
lstat = fs.lstatSync(absolutePath);
|
|
1919
2517
|
} catch {
|
|
1920
2518
|
console.error(` Warning: ${p} does not exist, skipping.`);
|
|
2519
|
+
hooks.onUnreachablePath?.(p, "missing");
|
|
1921
2520
|
continue;
|
|
1922
2521
|
}
|
|
1923
2522
|
|
|
@@ -1936,6 +2535,7 @@ function collectFiles(
|
|
|
1936
2535
|
if (lstat.isSymbolicLink()) {
|
|
1937
2536
|
if (!isWithinForLink(syncRoot, absolutePath)) {
|
|
1938
2537
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
2538
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1939
2539
|
continue;
|
|
1940
2540
|
}
|
|
1941
2541
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
@@ -1950,23 +2550,35 @@ function collectFiles(
|
|
|
1950
2550
|
// whole branch). The filter is pure path lookup with no I/O,
|
|
1951
2551
|
// so two calls are free.
|
|
1952
2552
|
if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
|
|
2553
|
+
const target = readlinkOrNull(absolutePath);
|
|
2554
|
+
if (target === null) {
|
|
2555
|
+
console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
|
|
2556
|
+
hooks.onUnreachablePath?.(p, "unreadable-link");
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2559
|
+
// A directory symlink whose target lives outside the company folder is
|
|
2560
|
+
// recorded here but never descended — its contents ship via their own
|
|
2561
|
+
// repo, not the vault. Surface it so files created under such a link
|
|
2562
|
+
// don't vanish from every push bucket silently.
|
|
2563
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
|
|
1953
2564
|
results.push({
|
|
1954
2565
|
kind: "symlink",
|
|
1955
2566
|
absolutePath,
|
|
1956
2567
|
relativePath,
|
|
1957
|
-
target
|
|
2568
|
+
target,
|
|
1958
2569
|
});
|
|
1959
2570
|
continue;
|
|
1960
2571
|
}
|
|
1961
2572
|
|
|
1962
|
-
if (!
|
|
2573
|
+
if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
|
|
1963
2574
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
2575
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1964
2576
|
continue;
|
|
1965
2577
|
}
|
|
1966
2578
|
|
|
1967
2579
|
if (lstat.isDirectory()) {
|
|
1968
2580
|
if (!filter(absolutePath, true)) continue;
|
|
1969
|
-
walkDir(absolutePath, syncRoot, filter, results);
|
|
2581
|
+
walkDir(absolutePath, syncRoot, filter, results, hooks);
|
|
1970
2582
|
} else if (lstat.isFile()) {
|
|
1971
2583
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
1972
2584
|
if (filter(absolutePath)) {
|
|
@@ -1983,6 +2595,7 @@ function walkDir(
|
|
|
1983
2595
|
syncRoot: string,
|
|
1984
2596
|
filter: (p: string, isDir?: boolean) => boolean,
|
|
1985
2597
|
results: CollectedEntry[],
|
|
2598
|
+
hooks: CollectHooks = {},
|
|
1986
2599
|
): void {
|
|
1987
2600
|
// A frame per open directory preserves the recursive walk's depth-first
|
|
1988
2601
|
// ordering without turning a completed subtree into one giant call argument
|
|
@@ -2028,15 +2641,26 @@ function walkDir(
|
|
|
2028
2641
|
// private/knowledge-{co}/), causing per-company knowledge repos
|
|
2029
2642
|
// to be uploaded into every vault that links them. Recording
|
|
2030
2643
|
// and not following preserves the link topology while avoiding
|
|
2031
|
-
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2644
|
+
const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2645
|
+
// On win32, a Dirent-known link is not sufficient proof that readlink
|
|
2646
|
+
// will succeed (notably for some reparse points). Never fall through to
|
|
2647
|
+
// normal file/directory handling here: doing so would dereference the
|
|
2648
|
+
// link and duplicate its target under this vault key.
|
|
2649
|
+
const target = readlinkOrNull(absolutePath);
|
|
2650
|
+
if (target === null) {
|
|
2651
|
+
console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
|
|
2652
|
+
hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2655
|
+
// The link is recorded but its (external) target is not descended, so
|
|
2656
|
+
// any files under it are NOT uploaded. Report the subtree so a full
|
|
2657
|
+
// `sync now` no longer drops it from every bucket without a trace.
|
|
2658
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
|
|
2035
2659
|
results.push({
|
|
2036
2660
|
kind: "symlink",
|
|
2037
2661
|
absolutePath,
|
|
2038
|
-
relativePath:
|
|
2039
|
-
target
|
|
2662
|
+
relativePath: linkRelative,
|
|
2663
|
+
target,
|
|
2040
2664
|
});
|
|
2041
2665
|
continue;
|
|
2042
2666
|
}
|
|
@@ -2155,6 +2779,23 @@ function isWithinForLink(parent: string, linkPath: string): boolean {
|
|
|
2155
2779
|
*
|
|
2156
2780
|
* Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
|
|
2157
2781
|
* itself; this is the bidirectional-runner case.
|
|
2782
|
+
*
|
|
2783
|
+
* Path spellings are resolved with `resolveNamedPath`, the same resolver the
|
|
2784
|
+
* upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
|
|
2785
|
+
* like its absolute equivalent instead of silently resolving nowhere and
|
|
2786
|
+
* scoping nothing.
|
|
2787
|
+
*
|
|
2788
|
+
* Containment, however, deliberately stays on strict realpath `isWithin` and
|
|
2789
|
+
* does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
|
|
2790
|
+
* legs are asymmetric on purpose: the upload leg ships the files it was handed,
|
|
2791
|
+
* while a delete scope is a PREFIX that authorizes removing every remote object
|
|
2792
|
+
* beneath it. A linked subtree's contents are never walked (`walkDir` does not
|
|
2793
|
+
* descend external directory symlinks), so anchoring a delete scope on one
|
|
2794
|
+
* would compare an empty local walk against a populated remote prefix and sweep
|
|
2795
|
+
* the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
|
|
2796
|
+
* accepted cost is that deletes inside a linked subtree are not propagated,
|
|
2797
|
+
* which matches the snapshot semantics `hq sync push` already documents for
|
|
2798
|
+
* those paths.
|
|
2158
2799
|
*/
|
|
2159
2800
|
function resolveDeleteScopeRoots(
|
|
2160
2801
|
paths: string[],
|
|
@@ -2181,7 +2822,7 @@ function resolveDeleteScopeRoots(
|
|
|
2181
2822
|
prefixes.add(normalized);
|
|
2182
2823
|
}
|
|
2183
2824
|
for (const p of paths) {
|
|
2184
|
-
const absolutePath =
|
|
2825
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
2185
2826
|
if (!fs.existsSync(absolutePath)) continue;
|
|
2186
2827
|
if (!isWithin(syncRoot, absolutePath)) continue;
|
|
2187
2828
|
const stat = fs.statSync(absolutePath);
|
|
@@ -2480,6 +3121,22 @@ async function computeDeletePlan(
|
|
|
2480
3121
|
if (!inScope) continue;
|
|
2481
3122
|
inScopeJournalEntries++;
|
|
2482
3123
|
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
3124
|
+
|
|
3125
|
+
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
3126
|
+
// context, a journal entry at a literal `companies/…` key records a
|
|
3127
|
+
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
3128
|
+
// presign transport is rejected by the server validator
|
|
3129
|
+
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
3130
|
+
// straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
|
|
3131
|
+
// remote call). Personal-vault pushes (personalMode) carry legitimate
|
|
3132
|
+
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
3133
|
+
// Checked before the presentLocally gate: the poison file lives at the
|
|
3134
|
+
// doubled path and must drain even while still on disk.
|
|
3135
|
+
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
3136
|
+
plan.toTombstone.push(relativeKey);
|
|
3137
|
+
continue;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
2483
3140
|
let presentLocally = true;
|
|
2484
3141
|
try {
|
|
2485
3142
|
fs.lstatSync(localPath);
|
|
@@ -2516,20 +3173,6 @@ async function computeDeletePlan(
|
|
|
2516
3173
|
continue;
|
|
2517
3174
|
}
|
|
2518
3175
|
|
|
2519
|
-
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
2520
|
-
// context, a journal entry at a literal `companies/…` key records a
|
|
2521
|
-
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
2522
|
-
// presign transport is rejected by the server validator
|
|
2523
|
-
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
2524
|
-
// straight to `toTombstone` (journal drop, no remote call) — the local
|
|
2525
|
-
// journal entry drains; server-side cleanup of any poisoned object is an
|
|
2526
|
-
// operator action. Personal-vault pushes (personalMode) carry legitimate
|
|
2527
|
-
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
2528
|
-
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
2529
|
-
plan.toTombstone.push(relativeKey);
|
|
2530
|
-
continue;
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
3176
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
2534
3177
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|
|
2535
3178
|
// normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
|