@indigoai-us/hq-cloud 6.14.35 → 6.14.37
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-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +13 -3
- package/dist/bin/sync-runner-watch-loop.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 +87 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +179 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +504 -26
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +639 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +24 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +45 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +34 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +19 -3
- package/src/bin/sync-runner-watch-loop.ts +13 -3
- package/src/bin/sync-runner.test.ts +105 -2
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/share.test.ts +794 -2
- package/src/cli/share.ts +624 -26
- package/src/cli/sync.test.ts +42 -0
- package/src/cli/sync.ts +78 -3
package/src/cli/share.ts
CHANGED
|
@@ -71,6 +71,7 @@ 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";
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* Push-side fresh-collision convergence probe.
|
|
@@ -258,6 +259,26 @@ export function isMalformedVaultKey(key: string): boolean {
|
|
|
258
259
|
return key.includes("\\");
|
|
259
260
|
}
|
|
260
261
|
|
|
262
|
+
/**
|
|
263
|
+
* A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
|
|
264
|
+
* PERSONAL vault, where it is handled by the dedicated `personalMode` branch
|
|
265
|
+
* in `computePullPlan` (companies/* content a peer machine pushed into the
|
|
266
|
+
* personal bucket). A COMPANY-scoped vault is already anchored at its company
|
|
267
|
+
* root, so its keys are bucket-relative — a `companies/...` key there is a
|
|
268
|
+
* doubly-scoped corrupt object. The vault-service refuses to presign such a
|
|
269
|
+
* key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
|
|
270
|
+
* never materialize it and the whole company sync wedges at `errored` (runner
|
|
271
|
+
* exit 2) on every run. Verified live 2026-06-16: frogbear's
|
|
272
|
+
* `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
|
|
273
|
+
* was uploaded with a doubled key and broke every sync thereafter. The pull
|
|
274
|
+
* and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
|
|
275
|
+
* with the malformed-(backslash)-key filter above; the bogus objects
|
|
276
|
+
* themselves are cleaned server-side.
|
|
277
|
+
*/
|
|
278
|
+
export function isForbiddenCompanyVaultKey(key: string, personalMode: boolean): boolean {
|
|
279
|
+
return !personalMode && key.startsWith("companies/");
|
|
280
|
+
}
|
|
281
|
+
|
|
261
282
|
/**
|
|
262
283
|
* Test-only export. Kept under a `_testing` namespace so the module's public
|
|
263
284
|
* surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
|
|
@@ -271,6 +292,8 @@ export const _testing = {
|
|
|
271
292
|
EPHEMERAL_PATH_PATTERN,
|
|
272
293
|
wrapFilterWithIgnoreVisibility,
|
|
273
294
|
collectFiles,
|
|
295
|
+
resolveNamedPath,
|
|
296
|
+
isWithinLexicalOrReal,
|
|
274
297
|
};
|
|
275
298
|
|
|
276
299
|
/**
|
|
@@ -678,6 +701,63 @@ export interface ShareOptions {
|
|
|
678
701
|
* the network. See the consult in the Stage-2 classification pass.
|
|
679
702
|
*/
|
|
680
703
|
fileTombstones?: Map<string, CompanyTombstone>;
|
|
704
|
+
/**
|
|
705
|
+
* What to do when a path the caller EXPLICITLY named cannot be shipped —
|
|
706
|
+
* it does not resolve under any base, or it resolves outside the company
|
|
707
|
+
* folder (see `ShareResult.unreachablePaths`).
|
|
708
|
+
*
|
|
709
|
+
* - `"error"` (DEFAULT): a path that EXISTS on disk but resolves outside the
|
|
710
|
+
* company folder throws {@link UnreachablePushPathsError} BEFORE any upload
|
|
711
|
+
* runs, so the push is an atomic no-op and the CLI exits nonzero. This is
|
|
712
|
+
* ask #2 of feedback_a51cb63d — "error, not warn-skip, when the named file
|
|
713
|
+
* exists locally but is unreachable by the resolver". A user who typed a
|
|
714
|
+
* path and got "✓ Pushed 0 file(s)" had no way to know their content never
|
|
715
|
+
* left the machine. A path that resolves to NOTHING under any base is still
|
|
716
|
+
* only warn-recorded (see `collectFatalUnreachablePaths` for why bulk
|
|
717
|
+
* membership fanout depends on that).
|
|
718
|
+
* - `"warn"`: record it on the result + emit the `not-shipped` event and
|
|
719
|
+
* carry on, never throwing. For callers whose `paths` are INTERNAL walk
|
|
720
|
+
* roots rather than user input — the background sync runner — where a path
|
|
721
|
+
* disappearing mid-run is a benign race (a directory removed between the
|
|
722
|
+
* scan and the push) and must never fail an unattended sync.
|
|
723
|
+
*/
|
|
724
|
+
unreachablePathPolicy?: "error" | "warn";
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Why an explicitly-named push path could not be shipped. */
|
|
728
|
+
export type UnreachablePathReason = "missing" | "outside-company";
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Thrown by `share()` when a caller-named path cannot be pushed and
|
|
732
|
+
* `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
|
|
733
|
+
* are still being built, so NOTHING has been uploaded, journaled, or deleted
|
|
734
|
+
* when it surfaces — the failed push leaves no partial state behind.
|
|
735
|
+
*/
|
|
736
|
+
export class UnreachablePushPathsError extends Error {
|
|
737
|
+
/** Caller's original spellings, verbatim (see the CollectHooks contract). */
|
|
738
|
+
readonly paths: string[];
|
|
739
|
+
/** Per-path reason, keyed by the same original spelling. */
|
|
740
|
+
readonly reasons: Record<string, UnreachablePathReason>;
|
|
741
|
+
|
|
742
|
+
constructor(unreachable: ReadonlyMap<string, UnreachablePathReason>, syncRoot: string) {
|
|
743
|
+
const paths = [...unreachable.keys()];
|
|
744
|
+
const lines = paths.map((p) => {
|
|
745
|
+
const reason = unreachable.get(p);
|
|
746
|
+
return reason === "outside-company"
|
|
747
|
+
? ` · ${p} — resolves outside the company folder (${syncRoot})`
|
|
748
|
+
: ` · ${p} — not found under the hq root, the company folder, or the current directory`;
|
|
749
|
+
});
|
|
750
|
+
super(
|
|
751
|
+
`${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
|
|
752
|
+
`nothing was uploaded.\n${lines.join("\n")}\n` +
|
|
753
|
+
`A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
|
|
754
|
+
`(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
|
|
755
|
+
`outside HQ has to sync through whatever owns it, not the vault.`,
|
|
756
|
+
);
|
|
757
|
+
this.name = "UnreachablePushPathsError";
|
|
758
|
+
this.paths = paths;
|
|
759
|
+
this.reasons = Object.fromEntries(unreachable);
|
|
760
|
+
}
|
|
681
761
|
}
|
|
682
762
|
|
|
683
763
|
export interface ShareResult {
|
|
@@ -757,6 +837,34 @@ export interface ShareResult {
|
|
|
757
837
|
* once if this is > 0).
|
|
758
838
|
*/
|
|
759
839
|
filesExcludedByIgnore: number;
|
|
840
|
+
/**
|
|
841
|
+
* Paths the caller EXPLICITLY named for push that exist locally but the
|
|
842
|
+
* resolver could not place under the company folder (or could not find under
|
|
843
|
+
* any base). Empty in the common case (internal walk roots are always the
|
|
844
|
+
* reachable company folder). A non-empty list means the push did NOT ship
|
|
845
|
+
* something the caller asked for.
|
|
846
|
+
*
|
|
847
|
+
* Only ever non-empty under `unreachablePathPolicy: "warn"` — the DEFAULT
|
|
848
|
+
* `"error"` policy throws {@link UnreachablePushPathsError} instead of
|
|
849
|
+
* returning, so an interactive `hq sync push` fails loudly rather than
|
|
850
|
+
* reporting the pre-fix silent "Pushed 0 file(s)" success. This field is the
|
|
851
|
+
* warn-mode surface for unattended callers (sync runner / watcher).
|
|
852
|
+
*
|
|
853
|
+
* Entries are the caller's ORIGINAL spellings, verbatim — a relative token
|
|
854
|
+
* stays relative, an absolute token stays absolute — so the list is directly
|
|
855
|
+
* comparable to the `paths` input. See the CollectHooks spelling contract.
|
|
856
|
+
* Mirrors the `not-shipped` event with `reason: "unreachable-path"`.
|
|
857
|
+
*/
|
|
858
|
+
unreachablePaths: string[];
|
|
859
|
+
/**
|
|
860
|
+
* Company-relative keys of directory symlinks that were recorded as links but
|
|
861
|
+
* NOT descended because their target lives outside the company folder — their
|
|
862
|
+
* contents sync via their own repo, not the vault. Surfaced so files created
|
|
863
|
+
* under such a link (e.g. `companies/{co}/knowledge` → a linked repo) no
|
|
864
|
+
* longer vanish from every push bucket without a trace. Mirrors the
|
|
865
|
+
* `not-shipped` event with `reason: "linked-subtree"`.
|
|
866
|
+
*/
|
|
867
|
+
linkedSubtreesNotShipped: string[];
|
|
760
868
|
/**
|
|
761
869
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
762
870
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -935,6 +1043,18 @@ interface PushRunContext {
|
|
|
935
1043
|
scopeExcludedSet: Set<string>;
|
|
936
1044
|
ignoreExcludedSet: Set<string>;
|
|
937
1045
|
ignoreExcludedTotal: { value: number };
|
|
1046
|
+
/** Explicitly-named push paths that exist locally but the resolver could not
|
|
1047
|
+
* place under the company folder (or find at all), keyed by the CALLER'S
|
|
1048
|
+
* ORIGINAL spelling (see the CollectHooks spelling contract) and valued by
|
|
1049
|
+
* why it could not be shipped. Non-empty ⇒ the push did NOT ship something
|
|
1050
|
+
* the caller named — surfaced so a "Pushed 0 file(s)" is never a silent
|
|
1051
|
+
* false success, and (under the default `unreachablePathPolicy: "error"`)
|
|
1052
|
+
* raised as a hard failure before any upload runs. */
|
|
1053
|
+
unreachablePaths: Map<string, UnreachablePathReason>;
|
|
1054
|
+
/** Company-relative keys of directory symlinks recorded but not descended
|
|
1055
|
+
* because their target lives outside the company folder (contents sync via
|
|
1056
|
+
* their own repo, not the vault). */
|
|
1057
|
+
linkedSubtreeSet: Set<string>;
|
|
938
1058
|
}
|
|
939
1059
|
|
|
940
1060
|
interface ShareCounters {
|
|
@@ -1040,6 +1160,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
1040
1160
|
const onScopeExcluded = (rel: string) => {
|
|
1041
1161
|
scopeExcludedSet.add(rel);
|
|
1042
1162
|
};
|
|
1163
|
+
const unreachablePaths = new Map<string, UnreachablePathReason>();
|
|
1164
|
+
const linkedSubtreeSet = new Set<string>();
|
|
1043
1165
|
const baseFilter = options.personalMode === true
|
|
1044
1166
|
? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
|
|
1045
1167
|
: recordedIgnoreFilter;
|
|
@@ -1073,6 +1195,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
1073
1195
|
scopeExcludedSet,
|
|
1074
1196
|
ignoreExcludedSet,
|
|
1075
1197
|
ignoreExcludedTotal,
|
|
1198
|
+
unreachablePaths,
|
|
1199
|
+
linkedSubtreeSet,
|
|
1076
1200
|
};
|
|
1077
1201
|
}
|
|
1078
1202
|
|
|
@@ -1094,7 +1218,30 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1094
1218
|
run.hqRoot,
|
|
1095
1219
|
run.syncRoot,
|
|
1096
1220
|
run.shouldSync,
|
|
1221
|
+
{
|
|
1222
|
+
onUnreachablePath: (namedPath, reason) => {
|
|
1223
|
+
// First reason wins: a path is named once, and re-adding would only
|
|
1224
|
+
// churn the map ordering the error message and event sample rely on.
|
|
1225
|
+
if (!run.unreachablePaths.has(namedPath)) {
|
|
1226
|
+
run.unreachablePaths.set(namedPath, reason);
|
|
1227
|
+
}
|
|
1228
|
+
},
|
|
1229
|
+
onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
|
|
1230
|
+
},
|
|
1097
1231
|
);
|
|
1232
|
+
// Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
|
|
1233
|
+
// exists locally but is unreachable by the resolver". This is the throw that
|
|
1234
|
+
// makes it true end-to-end — the CLI's push handler already turns a thrown
|
|
1235
|
+
// error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
|
|
1236
|
+
// "✓ Pushed 0 file(s)" success can no longer happen for a file that is
|
|
1237
|
+
// sitting right there on disk. It fires HERE, before executeUploads, so a
|
|
1238
|
+
// failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
|
|
1239
|
+
// written, no delete propagated.
|
|
1240
|
+
const fatal = collectFatalUnreachablePaths(run);
|
|
1241
|
+
if (fatal.size > 0) {
|
|
1242
|
+
emitUnreachablePathEvent(run);
|
|
1243
|
+
throw new UnreachablePushPathsError(fatal, run.syncRoot);
|
|
1244
|
+
}
|
|
1098
1245
|
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
1099
1246
|
// root IS the company folder, so a local entry whose vault key starts with
|
|
1100
1247
|
// `companies/` can only come from a stale doubled local tree
|
|
@@ -1480,8 +1627,7 @@ async function executeUploads(
|
|
|
1480
1627
|
run.emit({
|
|
1481
1628
|
type: "error",
|
|
1482
1629
|
path: relativePath,
|
|
1483
|
-
message:
|
|
1484
|
-
retryErr instanceof Error ? retryErr.message : String(retryErr),
|
|
1630
|
+
message: describeError(retryErr),
|
|
1485
1631
|
});
|
|
1486
1632
|
}
|
|
1487
1633
|
return;
|
|
@@ -1501,7 +1647,7 @@ async function executeUploads(
|
|
|
1501
1647
|
run.emit({
|
|
1502
1648
|
type: "error",
|
|
1503
1649
|
path: relativePath,
|
|
1504
|
-
message:
|
|
1650
|
+
message: describeError(err),
|
|
1505
1651
|
});
|
|
1506
1652
|
}
|
|
1507
1653
|
};
|
|
@@ -1572,9 +1718,7 @@ async function writePushConflictMirror(
|
|
|
1572
1718
|
run.emit({
|
|
1573
1719
|
type: "error",
|
|
1574
1720
|
path: item.relativePath,
|
|
1575
|
-
message:
|
|
1576
|
-
"conflict mirror write failed: " +
|
|
1577
|
-
(mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)),
|
|
1721
|
+
message: "conflict mirror write failed: " + describeError(mirrorErr),
|
|
1578
1722
|
});
|
|
1579
1723
|
}
|
|
1580
1724
|
}
|
|
@@ -1654,7 +1798,7 @@ async function executeDeletes(
|
|
|
1654
1798
|
run.emit({
|
|
1655
1799
|
type: "error",
|
|
1656
1800
|
path: relativePath,
|
|
1657
|
-
message:
|
|
1801
|
+
message: describeError(err),
|
|
1658
1802
|
});
|
|
1659
1803
|
pathResults.push({
|
|
1660
1804
|
path: relativePath,
|
|
@@ -1665,6 +1809,51 @@ async function executeDeletes(
|
|
|
1665
1809
|
}
|
|
1666
1810
|
}
|
|
1667
1811
|
for (const relativePath of deletePlan.toTombstone) {
|
|
1812
|
+
const localPath = localPathForVaultKey(run.syncRoot, relativePath);
|
|
1813
|
+
try {
|
|
1814
|
+
const lstat = fs.lstatSync(localPath);
|
|
1815
|
+
const entry = run.journal.files[relativePath];
|
|
1816
|
+
if (lstat.isFile()) {
|
|
1817
|
+
const localHash = hashFile(localPath);
|
|
1818
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1819
|
+
run.emit({
|
|
1820
|
+
type: "error",
|
|
1821
|
+
path: relativePath,
|
|
1822
|
+
message:
|
|
1823
|
+
"scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1824
|
+
});
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
fs.unlinkSync(localPath);
|
|
1828
|
+
} else if (lstat.isSymbolicLink()) {
|
|
1829
|
+
const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
|
|
1830
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1831
|
+
run.emit({
|
|
1832
|
+
type: "error",
|
|
1833
|
+
path: relativePath,
|
|
1834
|
+
message:
|
|
1835
|
+
"scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1836
|
+
});
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
fs.unlinkSync(localPath);
|
|
1840
|
+
}
|
|
1841
|
+
} catch (err: unknown) {
|
|
1842
|
+
const code =
|
|
1843
|
+
err && typeof err === "object" && "code" in err
|
|
1844
|
+
? (err as { code?: string }).code
|
|
1845
|
+
: undefined;
|
|
1846
|
+
if (code !== "ENOENT") {
|
|
1847
|
+
run.emit({
|
|
1848
|
+
type: "error",
|
|
1849
|
+
path: relativePath,
|
|
1850
|
+
message: `tombstone unlink failed: ${
|
|
1851
|
+
err instanceof Error ? err.message : String(err)
|
|
1852
|
+
}`,
|
|
1853
|
+
});
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1668
1857
|
removeEntry(run.journal, relativePath);
|
|
1669
1858
|
counters.filesTombstoned++;
|
|
1670
1859
|
run.emit({
|
|
@@ -1748,6 +1937,77 @@ function finalizeShareJournal(run: PushRunContext): void {
|
|
|
1748
1937
|
samplePaths,
|
|
1749
1938
|
});
|
|
1750
1939
|
}
|
|
1940
|
+
|
|
1941
|
+
emitUnreachablePathEvent(run);
|
|
1942
|
+
|
|
1943
|
+
if (run.linkedSubtreeSet.size > 0) {
|
|
1944
|
+
run.emit({
|
|
1945
|
+
type: "not-shipped",
|
|
1946
|
+
reason: "linked-subtree",
|
|
1947
|
+
count: run.linkedSubtreeSet.size,
|
|
1948
|
+
samplePaths: sampleSet(run.linkedSubtreeSet),
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* Which unreachable named paths are FATAL under the run's policy.
|
|
1955
|
+
*
|
|
1956
|
+
* Under the default `"error"` policy only `"outside-company"` is fatal: the
|
|
1957
|
+
* entry is sitting on disk and the resolver refused to place it under the
|
|
1958
|
+
* company folder, which is precisely the "exists locally but is unreachable by
|
|
1959
|
+
* the resolver" case the report asks to turn into an error, and it cannot
|
|
1960
|
+
* happen for an internal walk root (a company folder is trivially inside
|
|
1961
|
+
* itself).
|
|
1962
|
+
*
|
|
1963
|
+
* `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
|
|
1964
|
+
* and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
|
|
1965
|
+
* one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
|
|
1966
|
+
* folder was never materialized locally; throwing there would turn "you haven't
|
|
1967
|
+
* pulled that company yet" into a hard failure of an unrelated multi-company
|
|
1968
|
+
* push. Same reasoning for a watcher path deleted between the event and the
|
|
1969
|
+
* push. Those callers get the loud report without the regression.
|
|
1970
|
+
*
|
|
1971
|
+
* `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
|
|
1972
|
+
* end to end (the background sync runner).
|
|
1973
|
+
*/
|
|
1974
|
+
function collectFatalUnreachablePaths(
|
|
1975
|
+
run: PushRunContext,
|
|
1976
|
+
): Map<string, UnreachablePathReason> {
|
|
1977
|
+
const fatal = new Map<string, UnreachablePathReason>();
|
|
1978
|
+
if (run.options.unreachablePathPolicy === "warn") return fatal;
|
|
1979
|
+
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
1980
|
+
if (reason === "outside-company") fatal.set(namedPath, reason);
|
|
1981
|
+
}
|
|
1982
|
+
return fatal;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* Emit the `not-shipped` / `unreachable-path` event for a run, if any named
|
|
1987
|
+
* path went unshipped. Shared by BOTH policies so the operator sees the same
|
|
1988
|
+
* report either way: the fail-fast path emits it immediately before throwing
|
|
1989
|
+
* (the journal finalizer never runs on a throw), and the `"warn"` path emits it
|
|
1990
|
+
* from the finalizer at the end of a successful run. Exactly one of those two
|
|
1991
|
+
* call sites can fire per run, so the event is never duplicated.
|
|
1992
|
+
*/
|
|
1993
|
+
function emitUnreachablePathEvent(run: PushRunContext): void {
|
|
1994
|
+
if (run.unreachablePaths.size === 0) return;
|
|
1995
|
+
run.emit({
|
|
1996
|
+
type: "not-shipped",
|
|
1997
|
+
reason: "unreachable-path",
|
|
1998
|
+
count: run.unreachablePaths.size,
|
|
1999
|
+
samplePaths: sampleSet(run.unreachablePaths.keys()),
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/** First up-to-`limit` members of an iterable, for bounded event payloads. */
|
|
2004
|
+
function sampleSet(set: Iterable<string>, limit = 10): string[] {
|
|
2005
|
+
const sample: string[] = [];
|
|
2006
|
+
for (const value of set) {
|
|
2007
|
+
sample.push(value);
|
|
2008
|
+
if (sample.length >= limit) break;
|
|
2009
|
+
}
|
|
2010
|
+
return sample;
|
|
1751
2011
|
}
|
|
1752
2012
|
|
|
1753
2013
|
function throwUploadWorkerErrors(workerErrors: Error[]): void {
|
|
@@ -1784,6 +2044,8 @@ function buildShareResult(
|
|
|
1784
2044
|
filesExcludedByPolicy: run.excludedSet.size,
|
|
1785
2045
|
filesExcludedByScope: run.scopeExcludedSet.size,
|
|
1786
2046
|
filesExcludedByIgnore: run.ignoreExcludedSet.size,
|
|
2047
|
+
unreachablePaths: [...run.unreachablePaths.keys()],
|
|
2048
|
+
linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
|
|
1787
2049
|
conflictPaths,
|
|
1788
2050
|
pathResults,
|
|
1789
2051
|
aborted,
|
|
@@ -1864,6 +2126,24 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
1864
2126
|
if (event.count > event.samplePaths.length) {
|
|
1865
2127
|
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1866
2128
|
}
|
|
2129
|
+
} else if (event.type === "not-shipped") {
|
|
2130
|
+
// The other "not silent" surface: content the walk saw but chose not to
|
|
2131
|
+
// ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
|
|
2132
|
+
if (event.reason === "unreachable-path") {
|
|
2133
|
+
console.warn(
|
|
2134
|
+
` ! ${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"}):`,
|
|
2135
|
+
);
|
|
2136
|
+
} else {
|
|
2137
|
+
console.warn(
|
|
2138
|
+
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
for (const p of event.samplePaths) {
|
|
2142
|
+
console.warn(` · ${p}`);
|
|
2143
|
+
}
|
|
2144
|
+
if (event.count > event.samplePaths.length) {
|
|
2145
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
2146
|
+
}
|
|
1867
2147
|
}
|
|
1868
2148
|
}
|
|
1869
2149
|
|
|
@@ -1881,6 +2161,285 @@ type CollectedEntry =
|
|
|
1881
2161
|
| { kind: "file"; absolutePath: string; relativePath: string }
|
|
1882
2162
|
| { kind: "symlink"; absolutePath: string; relativePath: string; target: string };
|
|
1883
2163
|
|
|
2164
|
+
/**
|
|
2165
|
+
* Optional visibility callbacks for {@link collectFiles} / {@link walkDir}.
|
|
2166
|
+
* They turn two previously-SILENT outcomes into surfaced signals — without
|
|
2167
|
+
* changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
|
|
2168
|
+
*
|
|
2169
|
+
* - `onUnreachablePath`: a path the caller EXPLICITLY named (never an internal
|
|
2170
|
+
* walk root — those are always the reachable company folder) that exists on
|
|
2171
|
+
* disk but the resolver could not place under the company folder
|
|
2172
|
+
* (`"outside-company"`), or could not resolve to an existing file under any
|
|
2173
|
+
* base (`"missing"`). Pre-fix this was a bare `console.error` warn-skip that
|
|
2174
|
+
* still let the push report "Pushed 0 file(s)" — a false success. The caller
|
|
2175
|
+
* can now turn a non-empty set into a real error / nonzero exit.
|
|
2176
|
+
*
|
|
2177
|
+
* SPELLING CONTRACT: `namedPath` is ALWAYS the caller's ORIGINAL token,
|
|
2178
|
+
* verbatim — never the resolved absolute path, and never normalized. A
|
|
2179
|
+
* relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
|
|
2180
|
+
* an absolute path is reported absolute because that is what was passed.
|
|
2181
|
+
* Echoing the caller's own spelling is what makes the error actionable
|
|
2182
|
+
* ("the thing you typed did not ship"), and it keeps `unreachablePaths`
|
|
2183
|
+
* comparable to the input array without a resolution step. Both call sites
|
|
2184
|
+
* below pass the loop variable `p` for exactly this reason.
|
|
2185
|
+
* - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
|
|
2186
|
+
* descended because its target resolves OUTSIDE the company folder (e.g.
|
|
2187
|
+
* `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
|
|
2188
|
+
* contents ship via their own repo, not the vault; reporting it stops files
|
|
2189
|
+
* created under such a link from vanishing from every push bucket silently.
|
|
2190
|
+
*/
|
|
2191
|
+
interface CollectHooks {
|
|
2192
|
+
onUnreachablePath?: (namedPath: string, reason: "missing" | "outside-company") => void;
|
|
2193
|
+
onLinkedSubtree?: (rel: string) => void;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Resolve a caller-supplied push path to an absolute path.
|
|
2198
|
+
*
|
|
2199
|
+
* Relative paths were historically resolved against `hqRoot` ONLY, so a
|
|
2200
|
+
* company-relative spelling like `knowledge/agents/x.md` became
|
|
2201
|
+
* `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
|
|
2202
|
+
* caller's cwd or the company being pushed (feedback_258e4a86 /
|
|
2203
|
+
* feedback_a51cb63d — "there is no path spelling that reaches the file").
|
|
2204
|
+
*
|
|
2205
|
+
* PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
|
|
2206
|
+
* (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
|
|
2207
|
+
* ADDITIVE to the legacy behavior: every relative spelling that resolved
|
|
2208
|
+
* pre-fix still resolves to exactly the same file, and the two new bases only
|
|
2209
|
+
* catch spellings that previously resolved to nothing. Probing cwd first would
|
|
2210
|
+
* silently re-point existing callers (a `knowledge/` directory in the shell's
|
|
2211
|
+
* cwd would win over the hq-root one), which is a behavior change no reporter
|
|
2212
|
+
* asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
|
|
2213
|
+
* the unchanged "does not exist" diagnostic. Absolute paths are returned
|
|
2214
|
+
* verbatim.
|
|
2215
|
+
*
|
|
2216
|
+
* `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
|
|
2217
|
+
* rather than an ambient read, so resolution is deterministic and testable
|
|
2218
|
+
* without mutating process state.
|
|
2219
|
+
*/
|
|
2220
|
+
function resolveNamedPath(
|
|
2221
|
+
p: string,
|
|
2222
|
+
hqRoot: string,
|
|
2223
|
+
syncRoot: string,
|
|
2224
|
+
cwd: string = process.cwd(),
|
|
2225
|
+
): string {
|
|
2226
|
+
if (path.isAbsolute(p)) return p;
|
|
2227
|
+
const hqRootCandidate = path.resolve(hqRoot, p);
|
|
2228
|
+
const candidates = [
|
|
2229
|
+
hqRootCandidate,
|
|
2230
|
+
path.resolve(syncRoot, p),
|
|
2231
|
+
path.resolve(cwd, p),
|
|
2232
|
+
];
|
|
2233
|
+
for (const candidate of candidates) {
|
|
2234
|
+
try {
|
|
2235
|
+
fs.lstatSync(candidate);
|
|
2236
|
+
// An hqRoot hit outside the company folder would be rejected by
|
|
2237
|
+
// collectFiles as outside-company; skip it so a valid company-relative
|
|
2238
|
+
// spelling can win (Codex P2 — common `knowledge/` homonym case).
|
|
2239
|
+
if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
|
|
2240
|
+
continue;
|
|
2241
|
+
}
|
|
2242
|
+
return candidate;
|
|
2243
|
+
} catch {
|
|
2244
|
+
// Base did not resolve to an on-disk entry — try the next one.
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
return hqRootCandidate;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* Containment check for a regular file or directory that tolerates a symlinked
|
|
2252
|
+
* ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
|
|
2253
|
+
* path reached through a symlinked ancestor (`companies/{co}/knowledge` →
|
|
2254
|
+
* `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
|
|
2255
|
+
* rejected as "outside company folder" — even though its logical path is
|
|
2256
|
+
* in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
|
|
2257
|
+
* company-namespaced key for it. Accept when the LEXICAL path is inside
|
|
2258
|
+
* (honoring the same logical topology the vault key uses) OR the realpath is
|
|
2259
|
+
* inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
|
|
2260
|
+
*
|
|
2261
|
+
* The lexical arm carries TWO bounds, because it is the only place where a
|
|
2262
|
+
* path's bytes and its vault key come from different trees:
|
|
2263
|
+
*
|
|
2264
|
+
* 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
|
|
2265
|
+
* symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
|
|
2266
|
+
* state under a company-namespaced key.
|
|
2267
|
+
* 2. The TENANT — the realpath must not land inside another company's bytes
|
|
2268
|
+
* (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
|
|
2269
|
+
* must never be mistaken for a tenant boundary: hqRoot CONTAINS every
|
|
2270
|
+
* other company, so `companies/acme/knowledge → companies/other/secret`
|
|
2271
|
+
* (or → `repos/private/knowledge-other`, the linked-repo topology) is
|
|
2272
|
+
* lexically inside acme and really inside HQ, and would upload the other
|
|
2273
|
+
* tenant's bytes into acme's bucket under the key `knowledge/…`.
|
|
2274
|
+
*
|
|
2275
|
+
* The motivating topology (`companies/{co}/knowledge` →
|
|
2276
|
+
* `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
|
|
2277
|
+
* that escapes HQ, or one that reaches another tenant, is refused — and under
|
|
2278
|
+
* the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
|
|
2279
|
+
*
|
|
2280
|
+
* Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
|
|
2281
|
+
* externally-linked subtree can only be recognized while that company's folder
|
|
2282
|
+
* is materialized locally and publishes the link. A machine holding
|
|
2283
|
+
* `repos/private/knowledge-other` with no `companies/other` folder has no
|
|
2284
|
+
* on-disk evidence of the claim, so a link into it is indistinguishable from a
|
|
2285
|
+
* link into any other local repo. Ownership metadata (not path shape) is what
|
|
2286
|
+
* would close that gap.
|
|
2287
|
+
*/
|
|
2288
|
+
function isWithinLexicalOrReal(
|
|
2289
|
+
parent: string,
|
|
2290
|
+
child: string,
|
|
2291
|
+
hqRoot: string,
|
|
2292
|
+
tenantRootsCache?: Map<string, string[]>,
|
|
2293
|
+
): boolean {
|
|
2294
|
+
// Strict arm first: the realpath is genuinely inside the company folder.
|
|
2295
|
+
// This is the overwhelmingly common case, needs no relaxation, and costs no
|
|
2296
|
+
// directory scan.
|
|
2297
|
+
if (isWithin(parent, child)) return true;
|
|
2298
|
+
|
|
2299
|
+
const resolvedChild = path.resolve(child);
|
|
2300
|
+
if (!isPathWithin(path.resolve(parent), resolvedChild)) return false;
|
|
2301
|
+
|
|
2302
|
+
const childReal = realpathSafe(resolvedChild);
|
|
2303
|
+
if (!isWithin(hqRoot, childReal)) return false; // bound 1: escapes HQ
|
|
2304
|
+
|
|
2305
|
+
// NUL-joined: it is the one byte a path cannot contain, so no pair of
|
|
2306
|
+
// (hqRoot, parent) values can collide on the key.
|
|
2307
|
+
const cacheKey = `${hqRoot}\u0000${parent}`;
|
|
2308
|
+
let foreignRoots = tenantRootsCache?.get(cacheKey);
|
|
2309
|
+
if (foreignRoots === undefined) {
|
|
2310
|
+
foreignRoots = foreignTenantRoots(hqRoot, parent);
|
|
2311
|
+
tenantRootsCache?.set(cacheKey, foreignRoots);
|
|
2312
|
+
}
|
|
2313
|
+
for (const foreign of foreignRoots) {
|
|
2314
|
+
if (isPathWithin(foreign, childReal)) return false; // bound 2: other tenant
|
|
2315
|
+
}
|
|
2316
|
+
return true;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Depth (in path segments below a company root) at which we look for the
|
|
2321
|
+
* directory symlinks a company publishes into its own folder. HQ's linked
|
|
2322
|
+
* topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
|
|
2323
|
+
* (`companies/{co}/repos/{name}`); going deeper would turn a containment check
|
|
2324
|
+
* into a full-tree walk for no additional coverage.
|
|
2325
|
+
*/
|
|
2326
|
+
const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* Canonicalized roots that belong to a tenant OTHER than the one being pushed.
|
|
2330
|
+
* A lexically-contained path whose realpath lands inside any of these is
|
|
2331
|
+
* another company's data wearing this company's key, and must be refused.
|
|
2332
|
+
*
|
|
2333
|
+
* Two kinds of root are collected per foreign company:
|
|
2334
|
+
* - the company folder itself (`companies/{other}`), and
|
|
2335
|
+
* - the targets of the directory symlinks that folder publishes — HQ's
|
|
2336
|
+
* pattern-2 topology puts a company's knowledge in `repos/private/
|
|
2337
|
+
* knowledge-{other}` and links it in, so the bytes live OUTSIDE every
|
|
2338
|
+
* `companies/` root and a companies-only check would miss them entirely.
|
|
2339
|
+
*
|
|
2340
|
+
* Only consulted on the rare lexical arm (a path whose realpath is not inside
|
|
2341
|
+
* the company folder), never on the ordinary in-tree push, so the directory
|
|
2342
|
+
* scan is not on the hot path. Callers may memoize it for the duration of a
|
|
2343
|
+
* single collect pass; nothing memoizes it for longer, because the sync runner
|
|
2344
|
+
* is long-lived and a stale tenant map fails OPEN — the wrong direction for a
|
|
2345
|
+
* boundary whose whole job is to refuse.
|
|
2346
|
+
*/
|
|
2347
|
+
function foreignTenantRoots(hqRoot: string, syncRoot: string): string[] {
|
|
2348
|
+
const companiesDir = path.join(hqRoot, "companies");
|
|
2349
|
+
let entries: fs.Dirent[];
|
|
2350
|
+
try {
|
|
2351
|
+
entries = fs.readdirSync(companiesDir, { withFileTypes: true });
|
|
2352
|
+
} catch {
|
|
2353
|
+
return []; // no companies/ tree here — nothing to be foreign to
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
const activeReal = realpathSafe(syncRoot);
|
|
2357
|
+
const roots: string[] = [];
|
|
2358
|
+
for (const entry of entries) {
|
|
2359
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
2360
|
+
const companyRoot = path.join(companiesDir, entry.name);
|
|
2361
|
+
const companyReal = realpathSafe(companyRoot);
|
|
2362
|
+
if (companyReal === activeReal) continue; // this is the tenant being pushed
|
|
2363
|
+
roots.push(companyReal);
|
|
2364
|
+
collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
|
|
2365
|
+
}
|
|
2366
|
+
return roots;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Append the resolved targets of the directory symlinks published under
|
|
2371
|
+
* `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
|
|
2372
|
+
* inside the company folder are skipped — they add no reach beyond the root
|
|
2373
|
+
* already recorded. Errors are swallowed per entry on purpose: an unreadable
|
|
2374
|
+
* sibling directory must narrow what we can prove, never abort the push it is
|
|
2375
|
+
* unrelated to.
|
|
2376
|
+
*/
|
|
2377
|
+
function collectPublishedLinkTargets(
|
|
2378
|
+
dir: string,
|
|
2379
|
+
companyReal: string,
|
|
2380
|
+
depth: number,
|
|
2381
|
+
out: string[],
|
|
2382
|
+
): void {
|
|
2383
|
+
if (depth <= 0) return;
|
|
2384
|
+
let entries: fs.Dirent[];
|
|
2385
|
+
try {
|
|
2386
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2387
|
+
} catch {
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
for (const entry of entries) {
|
|
2391
|
+
const child = path.join(dir, entry.name);
|
|
2392
|
+
if (entry.isSymbolicLink()) {
|
|
2393
|
+
let real: string;
|
|
2394
|
+
try {
|
|
2395
|
+
real = fs.realpathSync.native(child);
|
|
2396
|
+
} catch {
|
|
2397
|
+
continue; // dangling link claims nothing
|
|
2398
|
+
}
|
|
2399
|
+
try {
|
|
2400
|
+
if (!fs.statSync(real).isDirectory()) continue;
|
|
2401
|
+
} catch {
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
if (isPathWithin(companyReal, real)) continue; // no reach beyond the root
|
|
2405
|
+
out.push(real);
|
|
2406
|
+
} else if (entry.isDirectory()) {
|
|
2407
|
+
collectPublishedLinkTargets(child, companyReal, depth - 1, out);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
|
|
2414
|
+
* `onLinkedSubtree` so the caller can report that the link's contents were not
|
|
2415
|
+
* uploaded to the vault. Fires only for links that (a) resolve to a directory
|
|
2416
|
+
* and (b) point outside the company folder — an in-tree link is descended
|
|
2417
|
+
* elsewhere, and a dangling or file link has nothing behind it to report.
|
|
2418
|
+
*/
|
|
2419
|
+
function reportLinkedSubtreeIfExternal(
|
|
2420
|
+
linkPath: string,
|
|
2421
|
+
syncRoot: string,
|
|
2422
|
+
relativePath: string,
|
|
2423
|
+
hooks: CollectHooks,
|
|
2424
|
+
): void {
|
|
2425
|
+
if (!hooks.onLinkedSubtree) return;
|
|
2426
|
+
let real: string;
|
|
2427
|
+
try {
|
|
2428
|
+
real = fs.realpathSync.native(linkPath); // resolves the link to its target
|
|
2429
|
+
} catch {
|
|
2430
|
+
return; // dangling link — nothing behind it to report
|
|
2431
|
+
}
|
|
2432
|
+
let targetStat: fs.Stats;
|
|
2433
|
+
try {
|
|
2434
|
+
targetStat = fs.statSync(real);
|
|
2435
|
+
} catch {
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
if (!targetStat.isDirectory()) return;
|
|
2439
|
+
if (isWithin(syncRoot, real)) return; // in-tree target: descended elsewhere
|
|
2440
|
+
hooks.onLinkedSubtree(relativePath);
|
|
2441
|
+
}
|
|
2442
|
+
|
|
1884
2443
|
/**
|
|
1885
2444
|
* Collect files from paths (expanding directories recursively).
|
|
1886
2445
|
*
|
|
@@ -1898,11 +2457,17 @@ function collectFiles(
|
|
|
1898
2457
|
hqRoot: string,
|
|
1899
2458
|
syncRoot: string,
|
|
1900
2459
|
filter: (p: string, isDir?: boolean) => boolean,
|
|
2460
|
+
hooks: CollectHooks = {},
|
|
1901
2461
|
): CollectedEntry[] {
|
|
1902
2462
|
const results: CollectedEntry[] = [];
|
|
2463
|
+
// Scoped to THIS collect pass and discarded with it: a watcher batch can
|
|
2464
|
+
// name hundreds of paths, and rescanning the companies/ tree for each one
|
|
2465
|
+
// is wasted I/O. A cache that outlived the pass could fail open on a tenant
|
|
2466
|
+
// boundary, so it deliberately does not.
|
|
2467
|
+
const tenantRoots = new Map<string, string[]>();
|
|
1903
2468
|
|
|
1904
2469
|
for (const p of paths) {
|
|
1905
|
-
const absolutePath =
|
|
2470
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
1906
2471
|
|
|
1907
2472
|
// Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
|
|
1908
2473
|
// Caller may pass one explicitly; we still refuse to upload it. Basename
|
|
@@ -1918,6 +2483,7 @@ function collectFiles(
|
|
|
1918
2483
|
lstat = fs.lstatSync(absolutePath);
|
|
1919
2484
|
} catch {
|
|
1920
2485
|
console.error(` Warning: ${p} does not exist, skipping.`);
|
|
2486
|
+
hooks.onUnreachablePath?.(p, "missing");
|
|
1921
2487
|
continue;
|
|
1922
2488
|
}
|
|
1923
2489
|
|
|
@@ -1936,6 +2502,7 @@ function collectFiles(
|
|
|
1936
2502
|
if (lstat.isSymbolicLink()) {
|
|
1937
2503
|
if (!isWithinForLink(syncRoot, absolutePath)) {
|
|
1938
2504
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
2505
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1939
2506
|
continue;
|
|
1940
2507
|
}
|
|
1941
2508
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
@@ -1950,6 +2517,11 @@ function collectFiles(
|
|
|
1950
2517
|
// whole branch). The filter is pure path lookup with no I/O,
|
|
1951
2518
|
// so two calls are free.
|
|
1952
2519
|
if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
|
|
2520
|
+
// A directory symlink whose target lives outside the company folder is
|
|
2521
|
+
// recorded here but never descended — its contents ship via their own
|
|
2522
|
+
// repo, not the vault. Surface it so files created under such a link
|
|
2523
|
+
// don't vanish from every push bucket silently.
|
|
2524
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
|
|
1953
2525
|
results.push({
|
|
1954
2526
|
kind: "symlink",
|
|
1955
2527
|
absolutePath,
|
|
@@ -1959,14 +2531,15 @@ function collectFiles(
|
|
|
1959
2531
|
continue;
|
|
1960
2532
|
}
|
|
1961
2533
|
|
|
1962
|
-
if (!
|
|
2534
|
+
if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
|
|
1963
2535
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
2536
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1964
2537
|
continue;
|
|
1965
2538
|
}
|
|
1966
2539
|
|
|
1967
2540
|
if (lstat.isDirectory()) {
|
|
1968
2541
|
if (!filter(absolutePath, true)) continue;
|
|
1969
|
-
walkDir(absolutePath, syncRoot, filter, results);
|
|
2542
|
+
walkDir(absolutePath, syncRoot, filter, results, hooks);
|
|
1970
2543
|
} else if (lstat.isFile()) {
|
|
1971
2544
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
1972
2545
|
if (filter(absolutePath)) {
|
|
@@ -1983,6 +2556,7 @@ function walkDir(
|
|
|
1983
2556
|
syncRoot: string,
|
|
1984
2557
|
filter: (p: string, isDir?: boolean) => boolean,
|
|
1985
2558
|
results: CollectedEntry[],
|
|
2559
|
+
hooks: CollectHooks = {},
|
|
1986
2560
|
): void {
|
|
1987
2561
|
// A frame per open directory preserves the recursive walk's depth-first
|
|
1988
2562
|
// ordering without turning a completed subtree into one giant call argument
|
|
@@ -2032,10 +2606,15 @@ function walkDir(
|
|
|
2032
2606
|
// fail under normal conditions; let the throw propagate if it
|
|
2033
2607
|
// somehow does (race with rm, EPERM) — the operator needs to
|
|
2034
2608
|
// see it rather than us silently dropping the link again.
|
|
2609
|
+
const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2610
|
+
// The link is recorded but its (external) target is not descended, so
|
|
2611
|
+
// any files under it are NOT uploaded. Report the subtree so a full
|
|
2612
|
+
// `sync now` no longer drops it from every bucket without a trace.
|
|
2613
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
|
|
2035
2614
|
results.push({
|
|
2036
2615
|
kind: "symlink",
|
|
2037
2616
|
absolutePath,
|
|
2038
|
-
relativePath:
|
|
2617
|
+
relativePath: linkRelative,
|
|
2039
2618
|
target: fs.readlinkSync(absolutePath),
|
|
2040
2619
|
});
|
|
2041
2620
|
continue;
|
|
@@ -2155,6 +2734,23 @@ function isWithinForLink(parent: string, linkPath: string): boolean {
|
|
|
2155
2734
|
*
|
|
2156
2735
|
* Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
|
|
2157
2736
|
* itself; this is the bidirectional-runner case.
|
|
2737
|
+
*
|
|
2738
|
+
* Path spellings are resolved with `resolveNamedPath`, the same resolver the
|
|
2739
|
+
* upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
|
|
2740
|
+
* like its absolute equivalent instead of silently resolving nowhere and
|
|
2741
|
+
* scoping nothing.
|
|
2742
|
+
*
|
|
2743
|
+
* Containment, however, deliberately stays on strict realpath `isWithin` and
|
|
2744
|
+
* does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
|
|
2745
|
+
* legs are asymmetric on purpose: the upload leg ships the files it was handed,
|
|
2746
|
+
* while a delete scope is a PREFIX that authorizes removing every remote object
|
|
2747
|
+
* beneath it. A linked subtree's contents are never walked (`walkDir` does not
|
|
2748
|
+
* descend external directory symlinks), so anchoring a delete scope on one
|
|
2749
|
+
* would compare an empty local walk against a populated remote prefix and sweep
|
|
2750
|
+
* the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
|
|
2751
|
+
* accepted cost is that deletes inside a linked subtree are not propagated,
|
|
2752
|
+
* which matches the snapshot semantics `hq sync push` already documents for
|
|
2753
|
+
* those paths.
|
|
2158
2754
|
*/
|
|
2159
2755
|
function resolveDeleteScopeRoots(
|
|
2160
2756
|
paths: string[],
|
|
@@ -2181,7 +2777,7 @@ function resolveDeleteScopeRoots(
|
|
|
2181
2777
|
prefixes.add(normalized);
|
|
2182
2778
|
}
|
|
2183
2779
|
for (const p of paths) {
|
|
2184
|
-
const absolutePath =
|
|
2780
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
2185
2781
|
if (!fs.existsSync(absolutePath)) continue;
|
|
2186
2782
|
if (!isWithin(syncRoot, absolutePath)) continue;
|
|
2187
2783
|
const stat = fs.statSync(absolutePath);
|
|
@@ -2480,6 +3076,22 @@ async function computeDeletePlan(
|
|
|
2480
3076
|
if (!inScope) continue;
|
|
2481
3077
|
inScopeJournalEntries++;
|
|
2482
3078
|
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
3079
|
+
|
|
3080
|
+
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
3081
|
+
// context, a journal entry at a literal `companies/…` key records a
|
|
3082
|
+
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
3083
|
+
// presign transport is rejected by the server validator
|
|
3084
|
+
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
3085
|
+
// straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
|
|
3086
|
+
// remote call). Personal-vault pushes (personalMode) carry legitimate
|
|
3087
|
+
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
3088
|
+
// Checked before the presentLocally gate: the poison file lives at the
|
|
3089
|
+
// doubled path and must drain even while still on disk.
|
|
3090
|
+
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
3091
|
+
plan.toTombstone.push(relativeKey);
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
2483
3095
|
let presentLocally = true;
|
|
2484
3096
|
try {
|
|
2485
3097
|
fs.lstatSync(localPath);
|
|
@@ -2516,20 +3128,6 @@ async function computeDeletePlan(
|
|
|
2516
3128
|
continue;
|
|
2517
3129
|
}
|
|
2518
3130
|
|
|
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
3131
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
2534
3132
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|
|
2535
3133
|
// normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
|