@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/dist/cli/share.js
CHANGED
|
@@ -24,6 +24,7 @@ import { buildConflictId, buildConflictPath, readShortMachineId, } from "../lib/
|
|
|
24
24
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
25
25
|
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
26
26
|
import { VaultAuthError } from "../vault-client.js";
|
|
27
|
+
import { describeError } from "../lib/describe-error.js";
|
|
27
28
|
/**
|
|
28
29
|
* Push-side fresh-collision convergence probe.
|
|
29
30
|
*
|
|
@@ -199,6 +200,25 @@ export function isEphemeralPath(p) {
|
|
|
199
200
|
export function isMalformedVaultKey(key) {
|
|
200
201
|
return key.includes("\\");
|
|
201
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
|
|
205
|
+
* PERSONAL vault, where it is handled by the dedicated `personalMode` branch
|
|
206
|
+
* in `computePullPlan` (companies/* content a peer machine pushed into the
|
|
207
|
+
* personal bucket). A COMPANY-scoped vault is already anchored at its company
|
|
208
|
+
* root, so its keys are bucket-relative — a `companies/...` key there is a
|
|
209
|
+
* doubly-scoped corrupt object. The vault-service refuses to presign such a
|
|
210
|
+
* key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
|
|
211
|
+
* never materialize it and the whole company sync wedges at `errored` (runner
|
|
212
|
+
* exit 2) on every run. Verified live 2026-06-16: frogbear's
|
|
213
|
+
* `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
|
|
214
|
+
* was uploaded with a doubled key and broke every sync thereafter. The pull
|
|
215
|
+
* and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
|
|
216
|
+
* with the malformed-(backslash)-key filter above; the bogus objects
|
|
217
|
+
* themselves are cleaned server-side.
|
|
218
|
+
*/
|
|
219
|
+
export function isForbiddenCompanyVaultKey(key, personalMode) {
|
|
220
|
+
return !personalMode && key.startsWith("companies/");
|
|
221
|
+
}
|
|
202
222
|
/**
|
|
203
223
|
* Test-only export. Kept under a `_testing` namespace so the module's public
|
|
204
224
|
* surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
|
|
@@ -212,6 +232,8 @@ export const _testing = {
|
|
|
212
232
|
EPHEMERAL_PATH_PATTERN,
|
|
213
233
|
wrapFilterWithIgnoreVisibility,
|
|
214
234
|
collectFiles,
|
|
235
|
+
resolveNamedPath,
|
|
236
|
+
isWithinLexicalOrReal,
|
|
215
237
|
};
|
|
216
238
|
/**
|
|
217
239
|
* Pure Stage-1 pass for push: walk the candidate file list, hash each one,
|
|
@@ -344,6 +366,35 @@ function computePushPlan(filesToShare, journal, skipUnchanged) {
|
|
|
344
366
|
}
|
|
345
367
|
return { items, filesToUpload, bytesToUpload, filesToSkip };
|
|
346
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* Thrown by `share()` when a caller-named path cannot be pushed and
|
|
371
|
+
* `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
|
|
372
|
+
* are still being built, so NOTHING has been uploaded, journaled, or deleted
|
|
373
|
+
* when it surfaces — the failed push leaves no partial state behind.
|
|
374
|
+
*/
|
|
375
|
+
export class UnreachablePushPathsError extends Error {
|
|
376
|
+
/** Caller's original spellings, verbatim (see the CollectHooks contract). */
|
|
377
|
+
paths;
|
|
378
|
+
/** Per-path reason, keyed by the same original spelling. */
|
|
379
|
+
reasons;
|
|
380
|
+
constructor(unreachable, syncRoot) {
|
|
381
|
+
const paths = [...unreachable.keys()];
|
|
382
|
+
const lines = paths.map((p) => {
|
|
383
|
+
const reason = unreachable.get(p);
|
|
384
|
+
return reason === "outside-company"
|
|
385
|
+
? ` · ${p} — resolves outside the company folder (${syncRoot})`
|
|
386
|
+
: ` · ${p} — not found under the hq root, the company folder, or the current directory`;
|
|
387
|
+
});
|
|
388
|
+
super(`${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
|
|
389
|
+
`nothing was uploaded.\n${lines.join("\n")}\n` +
|
|
390
|
+
`A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
|
|
391
|
+
`(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
|
|
392
|
+
`outside HQ has to sync through whatever owns it, not the vault.`);
|
|
393
|
+
this.name = "UnreachablePushPathsError";
|
|
394
|
+
this.paths = paths;
|
|
395
|
+
this.reasons = Object.fromEntries(unreachable);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
347
398
|
/**
|
|
348
399
|
* A conditional-write fence rejection — the SDK's 412 (`name:
|
|
349
400
|
* "PreconditionFailed"`) or the presigned transport's mirror of it. Means
|
|
@@ -490,6 +541,8 @@ async function createPushRunContext(options) {
|
|
|
490
541
|
const onScopeExcluded = (rel) => {
|
|
491
542
|
scopeExcludedSet.add(rel);
|
|
492
543
|
};
|
|
544
|
+
const unreachablePaths = new Map();
|
|
545
|
+
const linkedSubtreeSet = new Set();
|
|
493
546
|
const baseFilter = options.personalMode === true
|
|
494
547
|
? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
|
|
495
548
|
: recordedIgnoreFilter;
|
|
@@ -523,6 +576,8 @@ async function createPushRunContext(options) {
|
|
|
523
576
|
scopeExcludedSet,
|
|
524
577
|
ignoreExcludedSet,
|
|
525
578
|
ignoreExcludedTotal,
|
|
579
|
+
unreachablePaths,
|
|
580
|
+
linkedSubtreeSet,
|
|
526
581
|
};
|
|
527
582
|
}
|
|
528
583
|
function createShareCounters() {
|
|
@@ -537,7 +592,29 @@ function createShareCounters() {
|
|
|
537
592
|
};
|
|
538
593
|
}
|
|
539
594
|
async function buildSharePlans(run) {
|
|
540
|
-
const collected = collectFiles(run.paths, run.hqRoot, run.syncRoot, run.shouldSync
|
|
595
|
+
const collected = collectFiles(run.paths, run.hqRoot, run.syncRoot, run.shouldSync, {
|
|
596
|
+
onUnreachablePath: (namedPath, reason) => {
|
|
597
|
+
// First reason wins: a path is named once, and re-adding would only
|
|
598
|
+
// churn the map ordering the error message and event sample rely on.
|
|
599
|
+
if (!run.unreachablePaths.has(namedPath)) {
|
|
600
|
+
run.unreachablePaths.set(namedPath, reason);
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
|
|
604
|
+
});
|
|
605
|
+
// Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
|
|
606
|
+
// exists locally but is unreachable by the resolver". This is the throw that
|
|
607
|
+
// makes it true end-to-end — the CLI's push handler already turns a thrown
|
|
608
|
+
// error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
|
|
609
|
+
// "✓ Pushed 0 file(s)" success can no longer happen for a file that is
|
|
610
|
+
// sitting right there on disk. It fires HERE, before executeUploads, so a
|
|
611
|
+
// failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
|
|
612
|
+
// written, no delete propagated.
|
|
613
|
+
const fatal = collectFatalUnreachablePaths(run);
|
|
614
|
+
if (fatal.size > 0) {
|
|
615
|
+
emitUnreachablePathEvent(run);
|
|
616
|
+
throw new UnreachablePushPathsError(fatal, run.syncRoot);
|
|
617
|
+
}
|
|
541
618
|
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
542
619
|
// root IS the company folder, so a local entry whose vault key starts with
|
|
543
620
|
// `companies/` can only come from a stale doubled local tree
|
|
@@ -839,7 +916,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
839
916
|
run.emit({
|
|
840
917
|
type: "error",
|
|
841
918
|
path: relativePath,
|
|
842
|
-
message:
|
|
919
|
+
message: describeError(retryErr),
|
|
843
920
|
});
|
|
844
921
|
}
|
|
845
922
|
return;
|
|
@@ -855,7 +932,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
855
932
|
run.emit({
|
|
856
933
|
type: "error",
|
|
857
934
|
path: relativePath,
|
|
858
|
-
message:
|
|
935
|
+
message: describeError(err),
|
|
859
936
|
});
|
|
860
937
|
}
|
|
861
938
|
};
|
|
@@ -919,8 +996,7 @@ async function writePushConflictMirror(run, item, remoteHash) {
|
|
|
919
996
|
run.emit({
|
|
920
997
|
type: "error",
|
|
921
998
|
path: item.relativePath,
|
|
922
|
-
message: "conflict mirror write failed: " +
|
|
923
|
-
(mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)),
|
|
999
|
+
message: "conflict mirror write failed: " + describeError(mirrorErr),
|
|
924
1000
|
});
|
|
925
1001
|
}
|
|
926
1002
|
}
|
|
@@ -992,7 +1068,7 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
992
1068
|
run.emit({
|
|
993
1069
|
type: "error",
|
|
994
1070
|
path: relativePath,
|
|
995
|
-
message:
|
|
1071
|
+
message: describeError(err),
|
|
996
1072
|
});
|
|
997
1073
|
pathResults.push({
|
|
998
1074
|
path: relativePath,
|
|
@@ -1003,6 +1079,48 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
1003
1079
|
}
|
|
1004
1080
|
}
|
|
1005
1081
|
for (const relativePath of deletePlan.toTombstone) {
|
|
1082
|
+
const localPath = localPathForVaultKey(run.syncRoot, relativePath);
|
|
1083
|
+
try {
|
|
1084
|
+
const lstat = fs.lstatSync(localPath);
|
|
1085
|
+
const entry = run.journal.files[relativePath];
|
|
1086
|
+
if (lstat.isFile()) {
|
|
1087
|
+
const localHash = hashFile(localPath);
|
|
1088
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1089
|
+
run.emit({
|
|
1090
|
+
type: "error",
|
|
1091
|
+
path: relativePath,
|
|
1092
|
+
message: "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1093
|
+
});
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
fs.unlinkSync(localPath);
|
|
1097
|
+
}
|
|
1098
|
+
else if (lstat.isSymbolicLink()) {
|
|
1099
|
+
const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
|
|
1100
|
+
if (entry?.hash && entry.hash !== localHash) {
|
|
1101
|
+
run.emit({
|
|
1102
|
+
type: "error",
|
|
1103
|
+
path: relativePath,
|
|
1104
|
+
message: "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1105
|
+
});
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
fs.unlinkSync(localPath);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
catch (err) {
|
|
1112
|
+
const code = err && typeof err === "object" && "code" in err
|
|
1113
|
+
? err.code
|
|
1114
|
+
: undefined;
|
|
1115
|
+
if (code !== "ENOENT") {
|
|
1116
|
+
run.emit({
|
|
1117
|
+
type: "error",
|
|
1118
|
+
path: relativePath,
|
|
1119
|
+
message: `tombstone unlink failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1120
|
+
});
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1006
1124
|
removeEntry(run.journal, relativePath);
|
|
1007
1125
|
counters.filesTombstoned++;
|
|
1008
1126
|
run.emit({
|
|
@@ -1085,6 +1203,74 @@ function finalizeShareJournal(run) {
|
|
|
1085
1203
|
samplePaths,
|
|
1086
1204
|
});
|
|
1087
1205
|
}
|
|
1206
|
+
emitUnreachablePathEvent(run);
|
|
1207
|
+
if (run.linkedSubtreeSet.size > 0) {
|
|
1208
|
+
run.emit({
|
|
1209
|
+
type: "not-shipped",
|
|
1210
|
+
reason: "linked-subtree",
|
|
1211
|
+
count: run.linkedSubtreeSet.size,
|
|
1212
|
+
samplePaths: sampleSet(run.linkedSubtreeSet),
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Which unreachable named paths are FATAL under the run's policy.
|
|
1218
|
+
*
|
|
1219
|
+
* Under the default `"error"` policy only `"outside-company"` is fatal: the
|
|
1220
|
+
* entry is sitting on disk and the resolver refused to place it under the
|
|
1221
|
+
* company folder, which is precisely the "exists locally but is unreachable by
|
|
1222
|
+
* the resolver" case the report asks to turn into an error, and it cannot
|
|
1223
|
+
* happen for an internal walk root (a company folder is trivially inside
|
|
1224
|
+
* itself).
|
|
1225
|
+
*
|
|
1226
|
+
* `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
|
|
1227
|
+
* and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
|
|
1228
|
+
* one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
|
|
1229
|
+
* folder was never materialized locally; throwing there would turn "you haven't
|
|
1230
|
+
* pulled that company yet" into a hard failure of an unrelated multi-company
|
|
1231
|
+
* push. Same reasoning for a watcher path deleted between the event and the
|
|
1232
|
+
* push. Those callers get the loud report without the regression.
|
|
1233
|
+
*
|
|
1234
|
+
* `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
|
|
1235
|
+
* end to end (the background sync runner).
|
|
1236
|
+
*/
|
|
1237
|
+
function collectFatalUnreachablePaths(run) {
|
|
1238
|
+
const fatal = new Map();
|
|
1239
|
+
if (run.options.unreachablePathPolicy === "warn")
|
|
1240
|
+
return fatal;
|
|
1241
|
+
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
1242
|
+
if (reason === "outside-company")
|
|
1243
|
+
fatal.set(namedPath, reason);
|
|
1244
|
+
}
|
|
1245
|
+
return fatal;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Emit the `not-shipped` / `unreachable-path` event for a run, if any named
|
|
1249
|
+
* path went unshipped. Shared by BOTH policies so the operator sees the same
|
|
1250
|
+
* report either way: the fail-fast path emits it immediately before throwing
|
|
1251
|
+
* (the journal finalizer never runs on a throw), and the `"warn"` path emits it
|
|
1252
|
+
* from the finalizer at the end of a successful run. Exactly one of those two
|
|
1253
|
+
* call sites can fire per run, so the event is never duplicated.
|
|
1254
|
+
*/
|
|
1255
|
+
function emitUnreachablePathEvent(run) {
|
|
1256
|
+
if (run.unreachablePaths.size === 0)
|
|
1257
|
+
return;
|
|
1258
|
+
run.emit({
|
|
1259
|
+
type: "not-shipped",
|
|
1260
|
+
reason: "unreachable-path",
|
|
1261
|
+
count: run.unreachablePaths.size,
|
|
1262
|
+
samplePaths: sampleSet(run.unreachablePaths.keys()),
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
/** First up-to-`limit` members of an iterable, for bounded event payloads. */
|
|
1266
|
+
function sampleSet(set, limit = 10) {
|
|
1267
|
+
const sample = [];
|
|
1268
|
+
for (const value of set) {
|
|
1269
|
+
sample.push(value);
|
|
1270
|
+
if (sample.length >= limit)
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
return sample;
|
|
1088
1274
|
}
|
|
1089
1275
|
function throwUploadWorkerErrors(workerErrors) {
|
|
1090
1276
|
if (workerErrors.length > 0) {
|
|
@@ -1112,6 +1298,8 @@ function buildShareResult(run, counters, filesRefusedStalePaths, conflictPaths,
|
|
|
1112
1298
|
filesExcludedByPolicy: run.excludedSet.size,
|
|
1113
1299
|
filesExcludedByScope: run.scopeExcludedSet.size,
|
|
1114
1300
|
filesExcludedByIgnore: run.ignoreExcludedSet.size,
|
|
1301
|
+
unreachablePaths: [...run.unreachablePaths.keys()],
|
|
1302
|
+
linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
|
|
1115
1303
|
conflictPaths,
|
|
1116
1304
|
pathResults,
|
|
1117
1305
|
aborted,
|
|
@@ -1189,6 +1377,260 @@ function defaultConsoleLogger(event) {
|
|
|
1189
1377
|
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1190
1378
|
}
|
|
1191
1379
|
}
|
|
1380
|
+
else if (event.type === "not-shipped") {
|
|
1381
|
+
// The other "not silent" surface: content the walk saw but chose not to
|
|
1382
|
+
// ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
|
|
1383
|
+
if (event.reason === "unreachable-path") {
|
|
1384
|
+
console.warn(` ! ${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"}):`);
|
|
1385
|
+
}
|
|
1386
|
+
else {
|
|
1387
|
+
console.warn(` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`);
|
|
1388
|
+
}
|
|
1389
|
+
for (const p of event.samplePaths) {
|
|
1390
|
+
console.warn(` · ${p}`);
|
|
1391
|
+
}
|
|
1392
|
+
if (event.count > event.samplePaths.length) {
|
|
1393
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Resolve a caller-supplied push path to an absolute path.
|
|
1399
|
+
*
|
|
1400
|
+
* Relative paths were historically resolved against `hqRoot` ONLY, so a
|
|
1401
|
+
* company-relative spelling like `knowledge/agents/x.md` became
|
|
1402
|
+
* `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
|
|
1403
|
+
* caller's cwd or the company being pushed (feedback_258e4a86 /
|
|
1404
|
+
* feedback_a51cb63d — "there is no path spelling that reaches the file").
|
|
1405
|
+
*
|
|
1406
|
+
* PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
|
|
1407
|
+
* (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
|
|
1408
|
+
* ADDITIVE to the legacy behavior: every relative spelling that resolved
|
|
1409
|
+
* pre-fix still resolves to exactly the same file, and the two new bases only
|
|
1410
|
+
* catch spellings that previously resolved to nothing. Probing cwd first would
|
|
1411
|
+
* silently re-point existing callers (a `knowledge/` directory in the shell's
|
|
1412
|
+
* cwd would win over the hq-root one), which is a behavior change no reporter
|
|
1413
|
+
* asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
|
|
1414
|
+
* the unchanged "does not exist" diagnostic. Absolute paths are returned
|
|
1415
|
+
* verbatim.
|
|
1416
|
+
*
|
|
1417
|
+
* `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
|
|
1418
|
+
* rather than an ambient read, so resolution is deterministic and testable
|
|
1419
|
+
* without mutating process state.
|
|
1420
|
+
*/
|
|
1421
|
+
function resolveNamedPath(p, hqRoot, syncRoot, cwd = process.cwd()) {
|
|
1422
|
+
if (path.isAbsolute(p))
|
|
1423
|
+
return p;
|
|
1424
|
+
const hqRootCandidate = path.resolve(hqRoot, p);
|
|
1425
|
+
const candidates = [
|
|
1426
|
+
hqRootCandidate,
|
|
1427
|
+
path.resolve(syncRoot, p),
|
|
1428
|
+
path.resolve(cwd, p),
|
|
1429
|
+
];
|
|
1430
|
+
for (const candidate of candidates) {
|
|
1431
|
+
try {
|
|
1432
|
+
fs.lstatSync(candidate);
|
|
1433
|
+
// An hqRoot hit outside the company folder would be rejected by
|
|
1434
|
+
// collectFiles as outside-company; skip it so a valid company-relative
|
|
1435
|
+
// spelling can win (Codex P2 — common `knowledge/` homonym case).
|
|
1436
|
+
if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
return candidate;
|
|
1440
|
+
}
|
|
1441
|
+
catch {
|
|
1442
|
+
// Base did not resolve to an on-disk entry — try the next one.
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return hqRootCandidate;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Containment check for a regular file or directory that tolerates a symlinked
|
|
1449
|
+
* ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
|
|
1450
|
+
* path reached through a symlinked ancestor (`companies/{co}/knowledge` →
|
|
1451
|
+
* `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
|
|
1452
|
+
* rejected as "outside company folder" — even though its logical path is
|
|
1453
|
+
* in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
|
|
1454
|
+
* company-namespaced key for it. Accept when the LEXICAL path is inside
|
|
1455
|
+
* (honoring the same logical topology the vault key uses) OR the realpath is
|
|
1456
|
+
* inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
|
|
1457
|
+
*
|
|
1458
|
+
* The lexical arm carries TWO bounds, because it is the only place where a
|
|
1459
|
+
* path's bytes and its vault key come from different trees:
|
|
1460
|
+
*
|
|
1461
|
+
* 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
|
|
1462
|
+
* symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
|
|
1463
|
+
* state under a company-namespaced key.
|
|
1464
|
+
* 2. The TENANT — the realpath must not land inside another company's bytes
|
|
1465
|
+
* (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
|
|
1466
|
+
* must never be mistaken for a tenant boundary: hqRoot CONTAINS every
|
|
1467
|
+
* other company, so `companies/acme/knowledge → companies/other/secret`
|
|
1468
|
+
* (or → `repos/private/knowledge-other`, the linked-repo topology) is
|
|
1469
|
+
* lexically inside acme and really inside HQ, and would upload the other
|
|
1470
|
+
* tenant's bytes into acme's bucket under the key `knowledge/…`.
|
|
1471
|
+
*
|
|
1472
|
+
* The motivating topology (`companies/{co}/knowledge` →
|
|
1473
|
+
* `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
|
|
1474
|
+
* that escapes HQ, or one that reaches another tenant, is refused — and under
|
|
1475
|
+
* the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
|
|
1476
|
+
*
|
|
1477
|
+
* Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
|
|
1478
|
+
* externally-linked subtree can only be recognized while that company's folder
|
|
1479
|
+
* is materialized locally and publishes the link. A machine holding
|
|
1480
|
+
* `repos/private/knowledge-other` with no `companies/other` folder has no
|
|
1481
|
+
* on-disk evidence of the claim, so a link into it is indistinguishable from a
|
|
1482
|
+
* link into any other local repo. Ownership metadata (not path shape) is what
|
|
1483
|
+
* would close that gap.
|
|
1484
|
+
*/
|
|
1485
|
+
function isWithinLexicalOrReal(parent, child, hqRoot, tenantRootsCache) {
|
|
1486
|
+
// Strict arm first: the realpath is genuinely inside the company folder.
|
|
1487
|
+
// This is the overwhelmingly common case, needs no relaxation, and costs no
|
|
1488
|
+
// directory scan.
|
|
1489
|
+
if (isWithin(parent, child))
|
|
1490
|
+
return true;
|
|
1491
|
+
const resolvedChild = path.resolve(child);
|
|
1492
|
+
if (!isPathWithin(path.resolve(parent), resolvedChild))
|
|
1493
|
+
return false;
|
|
1494
|
+
const childReal = realpathSafe(resolvedChild);
|
|
1495
|
+
if (!isWithin(hqRoot, childReal))
|
|
1496
|
+
return false; // bound 1: escapes HQ
|
|
1497
|
+
// NUL-joined: it is the one byte a path cannot contain, so no pair of
|
|
1498
|
+
// (hqRoot, parent) values can collide on the key.
|
|
1499
|
+
const cacheKey = `${hqRoot}\u0000${parent}`;
|
|
1500
|
+
let foreignRoots = tenantRootsCache?.get(cacheKey);
|
|
1501
|
+
if (foreignRoots === undefined) {
|
|
1502
|
+
foreignRoots = foreignTenantRoots(hqRoot, parent);
|
|
1503
|
+
tenantRootsCache?.set(cacheKey, foreignRoots);
|
|
1504
|
+
}
|
|
1505
|
+
for (const foreign of foreignRoots) {
|
|
1506
|
+
if (isPathWithin(foreign, childReal))
|
|
1507
|
+
return false; // bound 2: other tenant
|
|
1508
|
+
}
|
|
1509
|
+
return true;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Depth (in path segments below a company root) at which we look for the
|
|
1513
|
+
* directory symlinks a company publishes into its own folder. HQ's linked
|
|
1514
|
+
* topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
|
|
1515
|
+
* (`companies/{co}/repos/{name}`); going deeper would turn a containment check
|
|
1516
|
+
* into a full-tree walk for no additional coverage.
|
|
1517
|
+
*/
|
|
1518
|
+
const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
|
|
1519
|
+
/**
|
|
1520
|
+
* Canonicalized roots that belong to a tenant OTHER than the one being pushed.
|
|
1521
|
+
* A lexically-contained path whose realpath lands inside any of these is
|
|
1522
|
+
* another company's data wearing this company's key, and must be refused.
|
|
1523
|
+
*
|
|
1524
|
+
* Two kinds of root are collected per foreign company:
|
|
1525
|
+
* - the company folder itself (`companies/{other}`), and
|
|
1526
|
+
* - the targets of the directory symlinks that folder publishes — HQ's
|
|
1527
|
+
* pattern-2 topology puts a company's knowledge in `repos/private/
|
|
1528
|
+
* knowledge-{other}` and links it in, so the bytes live OUTSIDE every
|
|
1529
|
+
* `companies/` root and a companies-only check would miss them entirely.
|
|
1530
|
+
*
|
|
1531
|
+
* Only consulted on the rare lexical arm (a path whose realpath is not inside
|
|
1532
|
+
* the company folder), never on the ordinary in-tree push, so the directory
|
|
1533
|
+
* scan is not on the hot path. Callers may memoize it for the duration of a
|
|
1534
|
+
* single collect pass; nothing memoizes it for longer, because the sync runner
|
|
1535
|
+
* is long-lived and a stale tenant map fails OPEN — the wrong direction for a
|
|
1536
|
+
* boundary whose whole job is to refuse.
|
|
1537
|
+
*/
|
|
1538
|
+
function foreignTenantRoots(hqRoot, syncRoot) {
|
|
1539
|
+
const companiesDir = path.join(hqRoot, "companies");
|
|
1540
|
+
let entries;
|
|
1541
|
+
try {
|
|
1542
|
+
entries = fs.readdirSync(companiesDir, { withFileTypes: true });
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
return []; // no companies/ tree here — nothing to be foreign to
|
|
1546
|
+
}
|
|
1547
|
+
const activeReal = realpathSafe(syncRoot);
|
|
1548
|
+
const roots = [];
|
|
1549
|
+
for (const entry of entries) {
|
|
1550
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
1551
|
+
continue;
|
|
1552
|
+
const companyRoot = path.join(companiesDir, entry.name);
|
|
1553
|
+
const companyReal = realpathSafe(companyRoot);
|
|
1554
|
+
if (companyReal === activeReal)
|
|
1555
|
+
continue; // this is the tenant being pushed
|
|
1556
|
+
roots.push(companyReal);
|
|
1557
|
+
collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
|
|
1558
|
+
}
|
|
1559
|
+
return roots;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Append the resolved targets of the directory symlinks published under
|
|
1563
|
+
* `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
|
|
1564
|
+
* inside the company folder are skipped — they add no reach beyond the root
|
|
1565
|
+
* already recorded. Errors are swallowed per entry on purpose: an unreadable
|
|
1566
|
+
* sibling directory must narrow what we can prove, never abort the push it is
|
|
1567
|
+
* unrelated to.
|
|
1568
|
+
*/
|
|
1569
|
+
function collectPublishedLinkTargets(dir, companyReal, depth, out) {
|
|
1570
|
+
if (depth <= 0)
|
|
1571
|
+
return;
|
|
1572
|
+
let entries;
|
|
1573
|
+
try {
|
|
1574
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1575
|
+
}
|
|
1576
|
+
catch {
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
for (const entry of entries) {
|
|
1580
|
+
const child = path.join(dir, entry.name);
|
|
1581
|
+
if (entry.isSymbolicLink()) {
|
|
1582
|
+
let real;
|
|
1583
|
+
try {
|
|
1584
|
+
real = fs.realpathSync.native(child);
|
|
1585
|
+
}
|
|
1586
|
+
catch {
|
|
1587
|
+
continue; // dangling link claims nothing
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
if (!fs.statSync(real).isDirectory())
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
catch {
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
if (isPathWithin(companyReal, real))
|
|
1597
|
+
continue; // no reach beyond the root
|
|
1598
|
+
out.push(real);
|
|
1599
|
+
}
|
|
1600
|
+
else if (entry.isDirectory()) {
|
|
1601
|
+
collectPublishedLinkTargets(child, companyReal, depth - 1, out);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
|
|
1607
|
+
* `onLinkedSubtree` so the caller can report that the link's contents were not
|
|
1608
|
+
* uploaded to the vault. Fires only for links that (a) resolve to a directory
|
|
1609
|
+
* and (b) point outside the company folder — an in-tree link is descended
|
|
1610
|
+
* elsewhere, and a dangling or file link has nothing behind it to report.
|
|
1611
|
+
*/
|
|
1612
|
+
function reportLinkedSubtreeIfExternal(linkPath, syncRoot, relativePath, hooks) {
|
|
1613
|
+
if (!hooks.onLinkedSubtree)
|
|
1614
|
+
return;
|
|
1615
|
+
let real;
|
|
1616
|
+
try {
|
|
1617
|
+
real = fs.realpathSync.native(linkPath); // resolves the link to its target
|
|
1618
|
+
}
|
|
1619
|
+
catch {
|
|
1620
|
+
return; // dangling link — nothing behind it to report
|
|
1621
|
+
}
|
|
1622
|
+
let targetStat;
|
|
1623
|
+
try {
|
|
1624
|
+
targetStat = fs.statSync(real);
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (!targetStat.isDirectory())
|
|
1630
|
+
return;
|
|
1631
|
+
if (isWithin(syncRoot, real))
|
|
1632
|
+
return; // in-tree target: descended elsewhere
|
|
1633
|
+
hooks.onLinkedSubtree(relativePath);
|
|
1192
1634
|
}
|
|
1193
1635
|
/**
|
|
1194
1636
|
* Collect files from paths (expanding directories recursively).
|
|
@@ -1202,10 +1644,15 @@ function defaultConsoleLogger(event) {
|
|
|
1202
1644
|
* Pre-fix, statSync followed the link and the target's bytes were uploaded
|
|
1203
1645
|
* under the link's key — silently flattening the link topology.
|
|
1204
1646
|
*/
|
|
1205
|
-
function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
1647
|
+
function collectFiles(paths, hqRoot, syncRoot, filter, hooks = {}) {
|
|
1206
1648
|
const results = [];
|
|
1649
|
+
// Scoped to THIS collect pass and discarded with it: a watcher batch can
|
|
1650
|
+
// name hundreds of paths, and rescanning the companies/ tree for each one
|
|
1651
|
+
// is wasted I/O. A cache that outlived the pass could fail open on a tenant
|
|
1652
|
+
// boundary, so it deliberately does not.
|
|
1653
|
+
const tenantRoots = new Map();
|
|
1207
1654
|
for (const p of paths) {
|
|
1208
|
-
const absolutePath =
|
|
1655
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
1209
1656
|
// Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
|
|
1210
1657
|
// Caller may pass one explicitly; we still refuse to upload it. Basename
|
|
1211
1658
|
// check matches the walkDir gate so behavior is identical whether the
|
|
@@ -1221,6 +1668,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1221
1668
|
}
|
|
1222
1669
|
catch {
|
|
1223
1670
|
console.error(` Warning: ${p} does not exist, skipping.`);
|
|
1671
|
+
hooks.onUnreachablePath?.(p, "missing");
|
|
1224
1672
|
continue;
|
|
1225
1673
|
}
|
|
1226
1674
|
// Containment check is split by entry kind: regular files and
|
|
@@ -1238,6 +1686,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1238
1686
|
if (lstat.isSymbolicLink()) {
|
|
1239
1687
|
if (!isWithinForLink(syncRoot, absolutePath)) {
|
|
1240
1688
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
1689
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1241
1690
|
continue;
|
|
1242
1691
|
}
|
|
1243
1692
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
@@ -1253,6 +1702,11 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1253
1702
|
// so two calls are free.
|
|
1254
1703
|
if (!filter(absolutePath, false) && !filter(absolutePath, true))
|
|
1255
1704
|
continue;
|
|
1705
|
+
// A directory symlink whose target lives outside the company folder is
|
|
1706
|
+
// recorded here but never descended — its contents ship via their own
|
|
1707
|
+
// repo, not the vault. Surface it so files created under such a link
|
|
1708
|
+
// don't vanish from every push bucket silently.
|
|
1709
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
|
|
1256
1710
|
results.push({
|
|
1257
1711
|
kind: "symlink",
|
|
1258
1712
|
absolutePath,
|
|
@@ -1261,14 +1715,15 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1261
1715
|
});
|
|
1262
1716
|
continue;
|
|
1263
1717
|
}
|
|
1264
|
-
if (!
|
|
1718
|
+
if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
|
|
1265
1719
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
1720
|
+
hooks.onUnreachablePath?.(p, "outside-company");
|
|
1266
1721
|
continue;
|
|
1267
1722
|
}
|
|
1268
1723
|
if (lstat.isDirectory()) {
|
|
1269
1724
|
if (!filter(absolutePath, true))
|
|
1270
1725
|
continue;
|
|
1271
|
-
walkDir(absolutePath, syncRoot, filter, results);
|
|
1726
|
+
walkDir(absolutePath, syncRoot, filter, results, hooks);
|
|
1272
1727
|
}
|
|
1273
1728
|
else if (lstat.isFile()) {
|
|
1274
1729
|
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
@@ -1279,7 +1734,7 @@ function collectFiles(paths, hqRoot, syncRoot, filter) {
|
|
|
1279
1734
|
}
|
|
1280
1735
|
return results;
|
|
1281
1736
|
}
|
|
1282
|
-
function walkDir(dir, syncRoot, filter, results) {
|
|
1737
|
+
function walkDir(dir, syncRoot, filter, results, hooks = {}) {
|
|
1283
1738
|
// A frame per open directory preserves the recursive walk's depth-first
|
|
1284
1739
|
// ordering without turning a completed subtree into one giant call argument
|
|
1285
1740
|
// list. This matters for both operator-visible plan ordering and large trees.
|
|
@@ -1329,10 +1784,15 @@ function walkDir(dir, syncRoot, filter, results) {
|
|
|
1329
1784
|
// fail under normal conditions; let the throw propagate if it
|
|
1330
1785
|
// somehow does (race with rm, EPERM) — the operator needs to
|
|
1331
1786
|
// see it rather than us silently dropping the link again.
|
|
1787
|
+
const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
1788
|
+
// The link is recorded but its (external) target is not descended, so
|
|
1789
|
+
// any files under it are NOT uploaded. Report the subtree so a full
|
|
1790
|
+
// `sync now` no longer drops it from every bucket without a trace.
|
|
1791
|
+
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
|
|
1332
1792
|
results.push({
|
|
1333
1793
|
kind: "symlink",
|
|
1334
1794
|
absolutePath,
|
|
1335
|
-
relativePath:
|
|
1795
|
+
relativePath: linkRelative,
|
|
1336
1796
|
target: fs.readlinkSync(absolutePath),
|
|
1337
1797
|
});
|
|
1338
1798
|
continue;
|
|
@@ -1449,6 +1909,23 @@ function isWithinForLink(parent, linkPath) {
|
|
|
1449
1909
|
*
|
|
1450
1910
|
* Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
|
|
1451
1911
|
* itself; this is the bidirectional-runner case.
|
|
1912
|
+
*
|
|
1913
|
+
* Path spellings are resolved with `resolveNamedPath`, the same resolver the
|
|
1914
|
+
* upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
|
|
1915
|
+
* like its absolute equivalent instead of silently resolving nowhere and
|
|
1916
|
+
* scoping nothing.
|
|
1917
|
+
*
|
|
1918
|
+
* Containment, however, deliberately stays on strict realpath `isWithin` and
|
|
1919
|
+
* does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
|
|
1920
|
+
* legs are asymmetric on purpose: the upload leg ships the files it was handed,
|
|
1921
|
+
* while a delete scope is a PREFIX that authorizes removing every remote object
|
|
1922
|
+
* beneath it. A linked subtree's contents are never walked (`walkDir` does not
|
|
1923
|
+
* descend external directory symlinks), so anchoring a delete scope on one
|
|
1924
|
+
* would compare an empty local walk against a populated remote prefix and sweep
|
|
1925
|
+
* the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
|
|
1926
|
+
* accepted cost is that deletes inside a linked subtree are not propagated,
|
|
1927
|
+
* which matches the snapshot semantics `hq sync push` already documents for
|
|
1928
|
+
* those paths.
|
|
1452
1929
|
*/
|
|
1453
1930
|
function resolveDeleteScopeRoots(paths, hqRoot, syncRoot, explicitRoots = []) {
|
|
1454
1931
|
const prefixes = new Set();
|
|
@@ -1468,7 +1945,7 @@ function resolveDeleteScopeRoots(paths, hqRoot, syncRoot, explicitRoots = []) {
|
|
|
1468
1945
|
prefixes.add(normalized);
|
|
1469
1946
|
}
|
|
1470
1947
|
for (const p of paths) {
|
|
1471
|
-
const absolutePath =
|
|
1948
|
+
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
1472
1949
|
if (!fs.existsSync(absolutePath))
|
|
1473
1950
|
continue;
|
|
1474
1951
|
if (!isWithin(syncRoot, absolutePath))
|
|
@@ -1689,6 +2166,20 @@ companyScoped = true) {
|
|
|
1689
2166
|
continue;
|
|
1690
2167
|
inScopeJournalEntries++;
|
|
1691
2168
|
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
2169
|
+
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
2170
|
+
// context, a journal entry at a literal `companies/…` key records a
|
|
2171
|
+
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
2172
|
+
// presign transport is rejected by the server validator
|
|
2173
|
+
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
2174
|
+
// straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
|
|
2175
|
+
// remote call). Personal-vault pushes (personalMode) carry legitimate
|
|
2176
|
+
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
2177
|
+
// Checked before the presentLocally gate: the poison file lives at the
|
|
2178
|
+
// doubled path and must drain even while still on disk.
|
|
2179
|
+
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
2180
|
+
plan.toTombstone.push(relativeKey);
|
|
2181
|
+
continue;
|
|
2182
|
+
}
|
|
1692
2183
|
let presentLocally = true;
|
|
1693
2184
|
try {
|
|
1694
2185
|
fs.lstatSync(localPath);
|
|
@@ -1723,19 +2214,6 @@ companyScoped = true) {
|
|
|
1723
2214
|
litterToDelete.push(relativeKey);
|
|
1724
2215
|
continue;
|
|
1725
2216
|
}
|
|
1726
|
-
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
1727
|
-
// context, a journal entry at a literal `companies/…` key records a
|
|
1728
|
-
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
1729
|
-
// presign transport is rejected by the server validator
|
|
1730
|
-
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
1731
|
-
// straight to `toTombstone` (journal drop, no remote call) — the local
|
|
1732
|
-
// journal entry drains; server-side cleanup of any poisoned object is an
|
|
1733
|
-
// operator action. Personal-vault pushes (personalMode) carry legitimate
|
|
1734
|
-
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
1735
|
-
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
1736
|
-
plan.toTombstone.push(relativeKey);
|
|
1737
|
-
continue;
|
|
1738
|
-
}
|
|
1739
2217
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true))
|
|
1740
2218
|
continue;
|
|
1741
2219
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|