@indigoai-us/hq-cloud 6.15.0 → 6.15.1
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-mutation.d.ts +14 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +64 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -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 +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.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 +52 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +31 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +82 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +221 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/cli/share.ts
DELETED
|
@@ -1,3663 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `hq share` command — selective push to entity vault (VLT-5 US-002).
|
|
3
|
-
*
|
|
4
|
-
* Broadcasts local file(s) to the company's S3 vault bucket.
|
|
5
|
-
* Refuses to overwrite a newer remote version without prompting.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as fs from "fs";
|
|
9
|
-
import * as os from "os";
|
|
10
|
-
import * as path from "path";
|
|
11
|
-
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
12
|
-
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
13
|
-
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
14
|
-
import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
|
|
15
|
-
import {
|
|
16
|
-
uploadFile,
|
|
17
|
-
uploadSymlink,
|
|
18
|
-
toPosixKey,
|
|
19
|
-
headRemoteFile,
|
|
20
|
-
deleteRemoteFile,
|
|
21
|
-
downloadFile,
|
|
22
|
-
primeObjectTransport,
|
|
23
|
-
primeUploads,
|
|
24
|
-
} from "../s3.js";
|
|
25
|
-
import * as crypto from "crypto";
|
|
26
|
-
import type { UploadAuthor } from "../s3.js";
|
|
27
|
-
import type { PutPrecondition } from "../object-io.js";
|
|
28
|
-
import {
|
|
29
|
-
readJournal,
|
|
30
|
-
writeJournal,
|
|
31
|
-
hashFile,
|
|
32
|
-
hashSymlinkTarget,
|
|
33
|
-
updateEntry,
|
|
34
|
-
removeEntry,
|
|
35
|
-
isTombstone,
|
|
36
|
-
normalizeEtag,
|
|
37
|
-
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
38
|
-
migratePersonalVaultJournal,
|
|
39
|
-
} from "../journal.js";
|
|
40
|
-
import {
|
|
41
|
-
createIgnoreFilter,
|
|
42
|
-
hasControlCharacters,
|
|
43
|
-
isExpectedIgnore,
|
|
44
|
-
isWithinSizeLimit,
|
|
45
|
-
} from "../ignore.js";
|
|
46
|
-
import {
|
|
47
|
-
wrapFilterWithPersonalVaultDefaults,
|
|
48
|
-
type PersonalVaultExclusion,
|
|
49
|
-
} from "../personal-vault-exclusions.js";
|
|
50
|
-
import {
|
|
51
|
-
isGeneratedCoreMirrorKey,
|
|
52
|
-
isPersonalVaultDeletionExcluded,
|
|
53
|
-
} from "../personal-vault.js";
|
|
54
|
-
import { resolveConflict } from "./conflict.js";
|
|
55
|
-
import type { ConflictStrategy } from "./conflict.js";
|
|
56
|
-
import type { SyncProgressEvent } from "./sync.js";
|
|
57
|
-
import {
|
|
58
|
-
fetchCompanyTombstones,
|
|
59
|
-
type CompanyTombstone,
|
|
60
|
-
} from "./tombstones.js";
|
|
61
|
-
import {
|
|
62
|
-
isCoveredByAny,
|
|
63
|
-
isDirInScope,
|
|
64
|
-
type ScopePrefixInput,
|
|
65
|
-
} from "../prefix-coalesce.js";
|
|
66
|
-
import {
|
|
67
|
-
hasRemoteChanged,
|
|
68
|
-
isAccessDenied,
|
|
69
|
-
resolveActiveCompany,
|
|
70
|
-
resolveTransferConcurrency,
|
|
71
|
-
} from "../sync-core.js";
|
|
72
|
-
import {
|
|
73
|
-
buildConflictId,
|
|
74
|
-
buildConflictPath,
|
|
75
|
-
readShortMachineId,
|
|
76
|
-
} from "../lib/conflict-file.js";
|
|
77
|
-
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
78
|
-
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
79
|
-
import { VaultAuthError } from "../vault-client.js";
|
|
80
|
-
import { describeError } from "../lib/describe-error.js";
|
|
81
|
-
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Push-side fresh-collision convergence probe.
|
|
85
|
-
*
|
|
86
|
-
* For a first push (no journal entry) where the remote object already
|
|
87
|
-
* exists, its etag is a version token rather than a reliable content hash,
|
|
88
|
-
* so we cannot tell "byte-identical" from "genuine divergence" without
|
|
89
|
-
* looking at the bytes. Fetch the remote object once to a throwaway temp
|
|
90
|
-
* file, hash it the same symlink-aware way the planner hashed local, and
|
|
91
|
-
* report whether the two contents DIFFER.
|
|
92
|
-
*
|
|
93
|
-
* Returns `true` on a genuine difference (a real fresh collision) and
|
|
94
|
-
* `false` when the bytes are identical. Fails safe to `true` on any
|
|
95
|
-
* fetch/hash error — a false positive merely prompts the operator, while a
|
|
96
|
-
* false negative could silently clobber a peer's content. Mirrors the
|
|
97
|
-
* pull-side convergence guard in `sync.ts`.
|
|
98
|
-
*
|
|
99
|
-
* The temp file lives in the OS temp dir (never under hqRoot) so it can
|
|
100
|
-
* never round-trip to S3, and is removed in a `finally` regardless of
|
|
101
|
-
* outcome. The name is derived from the relative path so concurrent probes
|
|
102
|
-
* for distinct files never collide on disk.
|
|
103
|
-
*/
|
|
104
|
-
async function remoteContentDiffers(
|
|
105
|
-
ctx: EntityContext,
|
|
106
|
-
relativePath: string,
|
|
107
|
-
localHash: string,
|
|
108
|
-
hqRoot: string,
|
|
109
|
-
): Promise<boolean> {
|
|
110
|
-
const probeKey = crypto
|
|
111
|
-
.createHash("sha256")
|
|
112
|
-
.update(relativePath)
|
|
113
|
-
.digest("hex")
|
|
114
|
-
.slice(0, 16);
|
|
115
|
-
const probePath = path.join(
|
|
116
|
-
os.tmpdir(),
|
|
117
|
-
`hq-conflict-probe-${readShortMachineId(hqRoot)}-${probeKey}.tmp`,
|
|
118
|
-
);
|
|
119
|
-
try {
|
|
120
|
-
await downloadFile(ctx, relativePath, probePath);
|
|
121
|
-
const remoteHash = fs.lstatSync(probePath).isSymbolicLink()
|
|
122
|
-
? hashSymlinkTarget(fs.readlinkSync(probePath))
|
|
123
|
-
: hashFile(probePath);
|
|
124
|
-
return remoteHash !== localHash;
|
|
125
|
-
} catch (err) {
|
|
126
|
-
if (err instanceof VaultAuthError) throw err;
|
|
127
|
-
return true;
|
|
128
|
-
} finally {
|
|
129
|
-
try {
|
|
130
|
-
fs.rmSync(probePath, { force: true });
|
|
131
|
-
} catch {
|
|
132
|
-
/* best-effort cleanup; a stray temp probe is harmless */
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Local-only ephemeral artifacts: conflict-mirror files written by the pull
|
|
139
|
-
* leg whenever a 3-way merge keeps local AND wants to preserve the remote
|
|
140
|
-
* version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
|
|
141
|
-
* (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
|
|
142
|
-
* or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
|
|
143
|
-
* originals produce no trailing dot, see `buildConflictPath` in
|
|
144
|
-
* `../lib/conflict-file.ts`).
|
|
145
|
-
*
|
|
146
|
-
* These files MUST never round-trip to S3 — they're local-only safety backups
|
|
147
|
-
* the user reviews and deletes once the merge is resolved. Pre-fix, the push
|
|
148
|
-
* walker happily uploaded them, the journal recorded them, and the
|
|
149
|
-
* `owned-only` delete policy then refused to clean them up when the user
|
|
150
|
-
* deleted them locally (because pull-confirmation had stamped them as
|
|
151
|
-
* `direction: "down"`). Net effect: a permanent litter ratchet on remote.
|
|
152
|
-
*
|
|
153
|
-
* Two known producer-shapes the regex must accommodate (both observed on
|
|
154
|
-
* affected user trees prior to this fix):
|
|
155
|
-
*
|
|
156
|
-
* 1. **`unknown` machine token.** Pre-`<hqRoot>/.hq/machine-id`
|
|
157
|
-
* provisioning (see `../lib/machine-id.ts`), hosts without
|
|
158
|
-
* `~/.hq/menubar.json` — every Linux HQ Pro Outpost, every fresh CLI
|
|
159
|
-
* install — fell through to the literal string `"unknown"` from the
|
|
160
|
-
* old `readShortMachineId()` fallback. The letters `k`, `n`, `o`, `w`
|
|
161
|
-
* live outside `[a-f]`, so the pre-fix `[a-f0-9]+` class refused those
|
|
162
|
-
* filenames. They round-tripped to S3 as ordinary files (which IS the
|
|
163
|
-
* "permanent litter ratchet" this module's contract was supposed to
|
|
164
|
-
* prevent). The new machine-id provisioning closes the producer side,
|
|
165
|
-
* but we still accept `unknown` here so legacy files already on disk
|
|
166
|
-
* are filtered out by the next push.
|
|
167
|
-
*
|
|
168
|
-
* 2. **Extensionless originals.** `path.extname('.gitignore')` returns
|
|
169
|
-
* `''` in Node, so `buildConflictPath` produces no trailing `.<ext>`
|
|
170
|
-
* segment for hidden-but-extensionless files like `.gitignore`,
|
|
171
|
-
* `.hqignore`, or any `.agents/skills`-style entry. The pre-fix `\.`
|
|
172
|
-
* tail was mandatory, so those names slipped through.
|
|
173
|
-
*
|
|
174
|
-
* Wire-points: (1) push walker — `collectFiles` / `walkDir` skip these so
|
|
175
|
-
* they never upload; (2) `computeDeletePlan` — skip these so an already-
|
|
176
|
-
* journaled mirror that's been deleted locally doesn't get included in the
|
|
177
|
-
* regular delete plan (the dedicated reconcile path handles existing litter).
|
|
178
|
-
*/
|
|
179
|
-
export const EPHEMERAL_PATH_PATTERN =
|
|
180
|
-
/\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-(?:[a-f0-9]+|unknown)(?:\.[^/]*)?$/;
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Cheap pure check — pass the relative key OR a basename; either works. Used
|
|
184
|
-
* in both the file walker (basename matching) and the delete-plan walker
|
|
185
|
-
* (relative-key matching), and also by the pull walker in sync.ts to refuse
|
|
186
|
-
* downloading legacy conflict-mirror files that still live in cloud staging
|
|
187
|
-
* (Bug #2 in the 5.33.0 deep-test report — push-side filtered them since
|
|
188
|
-
* 5.33.0 but pull-side downloaded them freely until this export). The regex
|
|
189
|
-
* matches anywhere in the string, which is fine: the
|
|
190
|
-
* `.conflict-<ISO>-<hash>.` token is unambiguous.
|
|
191
|
-
*/
|
|
192
|
-
/**
|
|
193
|
-
* Rescue-overlay drift marker pattern. Written by `replace-rescue.sh`
|
|
194
|
-
* (`rescue_one` / `conflict_one`) when its rescue target already exists:
|
|
195
|
-
* `<orig>.drift-<unix-ts>-<pid>` — a collision suffix so the previous override
|
|
196
|
-
* is never silently overwritten. Distinct from `EPHEMERAL_PATH_PATTERN` —
|
|
197
|
-
* different producer (the rescue script vs the sync conflict-mirror path),
|
|
198
|
-
* different filename grammar (decimal timestamp + decimal pid vs ISO timestamp
|
|
199
|
-
* + hex machine hash). Like conflict mirrors, drift markers should never live
|
|
200
|
-
* in the vault; if a buggy past run uploaded one, the delete plan must be
|
|
201
|
-
* able to drain it regardless of where it sits.
|
|
202
|
-
*/
|
|
203
|
-
export const DRIFT_PATH_PATTERN = /\.drift-\d+-\d+$/;
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* True iff the key is local-only ephemeral vault litter — a sync conflict
|
|
207
|
-
* mirror (`.conflict-<ISO>-<machine>[.ext]`) OR a rescue drift marker
|
|
208
|
-
* (`.drift-<unixts>-<pid>`).
|
|
209
|
-
*
|
|
210
|
-
* Used by `computeDeletePlan` to UNCONDITIONALLY drain existing vault litter,
|
|
211
|
-
* bypassing every "skip" gate that would otherwise trap it:
|
|
212
|
-
*
|
|
213
|
-
* 1. `shouldSync` — the personal-vault default exclusions (introduced in
|
|
214
|
-
* 5.25) reject paths like `**.obsidian/**`, `**.env`, `**output/**`,
|
|
215
|
-
* `**node_modules/**`, etc. from BOTH the upload walk AND the delete
|
|
216
|
-
* plan. That's correct for fresh content (don't upload), but it also
|
|
217
|
-
* strands any litter already in the vault at those paths — `<orig>.drift`
|
|
218
|
-
* / `<orig>.conflict` files inside an excluded parent get re-pulled
|
|
219
|
-
* every sync and never tombstoned. The live obsidian case here:
|
|
220
|
-
* `personal/.obsidian/graph.json.drift-1779863862-42519` survived every
|
|
221
|
-
* sync for two weeks because `.obsidian` is excluded.
|
|
222
|
-
* 2. `isEphemeralPath` — the existing skip in `computeDeletePlan` was
|
|
223
|
-
* designed to prevent a FRESH local `.conflict-*` mirror (written by the
|
|
224
|
-
* pull leg's "keep" branch as a side-by-side comparison file) from being
|
|
225
|
-
* miscounted as a delete candidate before the user has resolved the
|
|
226
|
-
* conflict. That intent stands, but it accidentally protects EXISTING
|
|
227
|
-
* cloud litter too. The fix: when the local file is already gone AND the
|
|
228
|
-
* key matches the litter pattern, drain it; the "fresh mirror, hasn't
|
|
229
|
-
* been resolved yet" case is impossible because that mirror would still
|
|
230
|
-
* be on disk.
|
|
231
|
-
* 3. The policy gate — `owned-only`'s direction filter and
|
|
232
|
-
* `currency-gated`'s etag check both encode user-content invariants
|
|
233
|
-
* that don't apply to litter (a `.drift-…-PID` file has no meaningful
|
|
234
|
-
* "ownership" or "freshness"; it's a stale local-overlay collision
|
|
235
|
-
* marker, by construction).
|
|
236
|
-
* 4. The bulk-asymmetry circuit breaker — litter cleanup is intentional
|
|
237
|
-
* ratchet-drain, not a "corrupt local mirror, refuse mass-delete"
|
|
238
|
-
* signal. Litter is queued via a separate bucket the breaker never
|
|
239
|
-
* sweeps.
|
|
240
|
-
*
|
|
241
|
-
* Together: a vault key matching either pattern always tombstones on the
|
|
242
|
-
* next push leg, regardless of personal-vault exclusions, ephemeral skip,
|
|
243
|
-
* policy, or breaker. Producer-side exclusions (upload walker + rescue
|
|
244
|
-
* script) close the ratchet on new litter; this drains the legacy buildup.
|
|
245
|
-
*/
|
|
246
|
-
export function isVaultLitterArtifact(p: string): boolean {
|
|
247
|
-
return EPHEMERAL_PATH_PATTERN.test(p) || DRIFT_PATH_PATTERN.test(p);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export function isEphemeralPath(p: string): boolean {
|
|
251
|
-
return EPHEMERAL_PATH_PATTERN.test(p);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* A vault key containing a backslash is never legitimate. HQ keys are POSIX
|
|
256
|
-
* (`toPosixKey` normalizes at every walker since 5.47.2 and `uploadFile`
|
|
257
|
-
* hard-normalizes at the S3 boundary), so a `\` in a remote key can only come
|
|
258
|
-
* from a pre-5.47.2 Windows client whose walker built keys with `path.sep` —
|
|
259
|
-
* verified live 2026-06-10: one such client duplicated 5,711 keys
|
|
260
|
-
* (`skills\demo-hq\SKILL.md`, …) into a company vault, and every up-to-date
|
|
261
|
-
* puller then materialized them as junk single-filename-with-backslash files
|
|
262
|
-
* that churned conflicts forever. The pull walker refuses these keys
|
|
263
|
-
* (skip-excluded-policy), symmetric with the ephemeral-mirror filter above.
|
|
264
|
-
*/
|
|
265
|
-
export function isMalformedVaultKey(key: string): boolean {
|
|
266
|
-
return key.includes("\\");
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
|
|
271
|
-
* PERSONAL vault, where it is handled by the dedicated `personalMode` branch
|
|
272
|
-
* in `computePullPlan` (companies/* content a peer machine pushed into the
|
|
273
|
-
* personal bucket). A COMPANY-scoped vault is already anchored at its company
|
|
274
|
-
* root, so its keys are bucket-relative — a `companies/...` key there is a
|
|
275
|
-
* doubly-scoped corrupt object. The vault-service refuses to presign such a
|
|
276
|
-
* key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
|
|
277
|
-
* never materialize it and the whole company sync wedges at `errored` (runner
|
|
278
|
-
* exit 2) on every run. Verified live 2026-06-16: frogbear's
|
|
279
|
-
* `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
|
|
280
|
-
* was uploaded with a doubled key and broke every sync thereafter. The pull
|
|
281
|
-
* and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
|
|
282
|
-
* with the malformed-(backslash)-key filter above; the bogus objects
|
|
283
|
-
* themselves are cleaned server-side.
|
|
284
|
-
*/
|
|
285
|
-
export function isForbiddenCompanyVaultKey(key: string, personalMode: boolean): boolean {
|
|
286
|
-
return !personalMode && key.startsWith("companies/");
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Test-only export. Kept under a `_testing` namespace so the module's public
|
|
291
|
-
* surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
|
|
292
|
-
* regression-critical regex contracts (the conflict-mirror pattern) can be
|
|
293
|
-
* pinned by direct unit tests without round-tripping through share().
|
|
294
|
-
*
|
|
295
|
-
* Do NOT import from `_testing` outside of tests in this package.
|
|
296
|
-
*/
|
|
297
|
-
export const _testing = {
|
|
298
|
-
isEphemeralPath,
|
|
299
|
-
EPHEMERAL_PATH_PATTERN,
|
|
300
|
-
wrapFilterWithIgnoreVisibility,
|
|
301
|
-
collectFiles,
|
|
302
|
-
resolveNamedPath,
|
|
303
|
-
isWithinLexicalOrReal,
|
|
304
|
-
defaultConsoleLogger,
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Stage-1 classification for a single local file in a push run. Pre-HEAD —
|
|
309
|
-
* only inputs we can evaluate locally (size limit, journal hash, optional
|
|
310
|
-
* skip-unchanged) determine the action. Files that pass classification as
|
|
311
|
-
* `upload` are still subject to a per-file HEAD + 3-way conflict check in
|
|
312
|
-
* Stage 2 before the actual PUT, so the `filesToUpload` count in the plan
|
|
313
|
-
* event is an upper bound: it includes files that may turn out to be
|
|
314
|
-
* conflicts. V1.5 follow-up: replace per-file HEAD with a single LIST so
|
|
315
|
-
* conflicts can be classified up-front and reported in the plan.
|
|
316
|
-
*/
|
|
317
|
-
type PushPlanItem =
|
|
318
|
-
| {
|
|
319
|
-
action: "upload";
|
|
320
|
-
kind: "file";
|
|
321
|
-
absolutePath: string;
|
|
322
|
-
relativePath: string;
|
|
323
|
-
localHash: string;
|
|
324
|
-
size: number;
|
|
325
|
-
}
|
|
326
|
-
| {
|
|
327
|
-
action: "upload";
|
|
328
|
-
kind: "symlink";
|
|
329
|
-
absolutePath: string;
|
|
330
|
-
relativePath: string;
|
|
331
|
-
// The link's target string verbatim (whatever readlink returned).
|
|
332
|
-
// Hashed into localHash so a target rewrite re-uploads even when
|
|
333
|
-
// skipUnchanged is on; size stays 0 because the wire body is empty.
|
|
334
|
-
target: string;
|
|
335
|
-
localHash: string;
|
|
336
|
-
size: 0;
|
|
337
|
-
}
|
|
338
|
-
| {
|
|
339
|
-
action: "skip-size-limit";
|
|
340
|
-
absolutePath: string;
|
|
341
|
-
relativePath: string;
|
|
342
|
-
}
|
|
343
|
-
| {
|
|
344
|
-
action: "skip-unchanged";
|
|
345
|
-
absolutePath: string;
|
|
346
|
-
relativePath: string;
|
|
347
|
-
// Present only when the lstat fast-path MISSED but the re-hash confirmed
|
|
348
|
-
// the bytes are unchanged (a touched-but-identical file, or a pre-5.36
|
|
349
|
-
// entry with no mtimeMs). Carries the current (mtimeMs, size) so the
|
|
350
|
-
// apply loop can refresh the journal entry — otherwise the fast-path
|
|
351
|
-
// keeps missing and re-hashes the file on every future sync. No content
|
|
352
|
-
// change: this never uploads or conflicts.
|
|
353
|
-
restamp?: { mtimeMs: number; size: number };
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
interface PushPlan {
|
|
357
|
-
items: PushPlanItem[];
|
|
358
|
-
filesToUpload: number;
|
|
359
|
-
bytesToUpload: number;
|
|
360
|
-
filesToSkip: number;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Pure Stage-1 pass for push: walk the candidate file list, hash each one,
|
|
365
|
-
* apply the size-limit and skip-unchanged gates, and return a classified
|
|
366
|
-
* plan plus aggregate counts. No S3 calls, no journal writes, no event
|
|
367
|
-
* emission.
|
|
368
|
-
*
|
|
369
|
-
* The conflict count is intentionally absent from the returned `PushPlan` —
|
|
370
|
-
* detecting a push conflict requires a remote HEAD that we defer to Stage 2.
|
|
371
|
-
* Consumers that want a conflict count get it from the `complete` event.
|
|
372
|
-
*/
|
|
373
|
-
function computePushPlan(
|
|
374
|
-
filesToShare: CollectedEntry[],
|
|
375
|
-
journal: SyncJournal,
|
|
376
|
-
skipUnchanged: boolean,
|
|
377
|
-
): PushPlan {
|
|
378
|
-
const items: PushPlanItem[] = [];
|
|
379
|
-
|
|
380
|
-
for (const entry of filesToShare) {
|
|
381
|
-
const { absolutePath, relativePath } = entry;
|
|
382
|
-
|
|
383
|
-
// Symlinks bypass the size-limit gate (the wire body is small,
|
|
384
|
-
// bounded by target length) and hash the target through the
|
|
385
|
-
// symlink-namespaced hash so a symlink to "real.md" can never
|
|
386
|
-
// collide with a regular file containing the bytes "real.md" in
|
|
387
|
-
// the skip-unchanged gate. The target is what we're actually
|
|
388
|
-
// uploading, so its hash is what should drive change detection —
|
|
389
|
-
// a target rewrite must re-fire an upload even when the link
|
|
390
|
-
// itself "looks" identical.
|
|
391
|
-
if (entry.kind === "symlink") {
|
|
392
|
-
const localHash = hashSymlinkTarget(entry.target);
|
|
393
|
-
|
|
394
|
-
if (skipUnchanged) {
|
|
395
|
-
const existing = journal.files[relativePath];
|
|
396
|
-
if (existing && existing.hash === localHash) {
|
|
397
|
-
items.push({ action: "skip-unchanged", absolutePath, relativePath });
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
items.push({
|
|
403
|
-
action: "upload",
|
|
404
|
-
kind: "symlink",
|
|
405
|
-
absolutePath,
|
|
406
|
-
relativePath,
|
|
407
|
-
target: entry.target,
|
|
408
|
-
localHash,
|
|
409
|
-
size: 0,
|
|
410
|
-
});
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (!isWithinSizeLimit(absolutePath)) {
|
|
415
|
-
items.push({ action: "skip-size-limit", absolutePath, relativePath });
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Fast-path (5.36.0): before SHA256-ing the file, lstat it and compare
|
|
420
|
-
// (size, mtimeMs) against the journal entry. When both match, treat the
|
|
421
|
-
// file as unchanged without reading its bytes. This is the same
|
|
422
|
-
// industry-standard "is it changed?" cheap-check rsync and gitignore
|
|
423
|
-
// use. Trade-off: a same-length edit that doesn't bump mtime will be
|
|
424
|
-
// missed — vanishingly rare in practice. The big win is on no-op syncs
|
|
425
|
-
// (most syncs): a 5000-file tree of mostly-unchanged content used to
|
|
426
|
-
// SHA256 every file on every walk. Now it lstats once and short-
|
|
427
|
-
// circuits in O(file count) instead of O(file bytes).
|
|
428
|
-
//
|
|
429
|
-
// Only applies when `skipUnchanged` is on AND the journal carries an
|
|
430
|
-
// `mtimeMs` for this path AND the path is a regular file (symlinks
|
|
431
|
-
// already hash via the cheap hashSymlinkTarget path above). Entries
|
|
432
|
-
// without `mtimeMs` (pre-5.36 journal) fall through to the hash path;
|
|
433
|
-
// the next upload stamps the field so subsequent syncs use the fast-
|
|
434
|
-
// path. Back-compat is automatic.
|
|
435
|
-
if (skipUnchanged) {
|
|
436
|
-
const existing = journal.files[relativePath];
|
|
437
|
-
if (existing && existing.mtimeMs !== undefined && existing.hash) {
|
|
438
|
-
try {
|
|
439
|
-
const lstat = fs.lstatSync(absolutePath);
|
|
440
|
-
if (
|
|
441
|
-
lstat.isFile() &&
|
|
442
|
-
lstat.size === existing.size &&
|
|
443
|
-
lstat.mtimeMs === existing.mtimeMs
|
|
444
|
-
) {
|
|
445
|
-
items.push({ action: "skip-unchanged", absolutePath, relativePath });
|
|
446
|
-
continue;
|
|
447
|
-
}
|
|
448
|
-
} catch {
|
|
449
|
-
// Fall through to hashFile, which will surface the I/O error in
|
|
450
|
-
// its own readFileSync.
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const localHash = hashFile(absolutePath);
|
|
456
|
-
|
|
457
|
-
if (skipUnchanged) {
|
|
458
|
-
const existing = journal.files[relativePath];
|
|
459
|
-
if (existing && existing.hash === localHash) {
|
|
460
|
-
// Reaching here means the fast-path did NOT short-circuit (mtime or
|
|
461
|
-
// size moved, or the entry predates mtimeMs) yet the re-hash proved the
|
|
462
|
-
// bytes are unchanged — a no-op skip. Capture the current (mtimeMs,
|
|
463
|
-
// size) so the apply loop refreshes the journal; without it the
|
|
464
|
-
// fast-path keeps missing and re-hashes this file on every sync. Only
|
|
465
|
-
// attach when the stored stat actually differs (avoid a needless
|
|
466
|
-
// journal write on an already-current entry). Stat failure → no
|
|
467
|
-
// restamp; the skip still stands.
|
|
468
|
-
let restamp: { mtimeMs: number; size: number } | undefined;
|
|
469
|
-
try {
|
|
470
|
-
const lstat = fs.lstatSync(absolutePath);
|
|
471
|
-
if (
|
|
472
|
-
lstat.isFile() &&
|
|
473
|
-
(existing.mtimeMs !== lstat.mtimeMs || existing.size !== lstat.size)
|
|
474
|
-
) {
|
|
475
|
-
restamp = { mtimeMs: lstat.mtimeMs, size: lstat.size };
|
|
476
|
-
}
|
|
477
|
-
} catch {
|
|
478
|
-
/* best-effort; a stat error just forgoes the fast-path refresh */
|
|
479
|
-
}
|
|
480
|
-
items.push({ action: "skip-unchanged", absolutePath, relativePath, restamp });
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const size = fs.statSync(absolutePath).size;
|
|
486
|
-
items.push({
|
|
487
|
-
action: "upload",
|
|
488
|
-
kind: "file",
|
|
489
|
-
absolutePath,
|
|
490
|
-
relativePath,
|
|
491
|
-
localHash,
|
|
492
|
-
size,
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
let filesToUpload = 0;
|
|
497
|
-
let bytesToUpload = 0;
|
|
498
|
-
let filesToSkip = 0;
|
|
499
|
-
for (const item of items) {
|
|
500
|
-
if (item.action === "upload") {
|
|
501
|
-
filesToUpload++;
|
|
502
|
-
bytesToUpload += item.size;
|
|
503
|
-
} else {
|
|
504
|
-
filesToSkip++;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
return { items, filesToUpload, bytesToUpload, filesToSkip };
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
export interface ShareOptions {
|
|
512
|
-
/** Path(s) to share (files or directories) */
|
|
513
|
-
paths: string[];
|
|
514
|
-
/** Company slug or UID (defaults to active company from config) */
|
|
515
|
-
company?: string;
|
|
516
|
-
/** Optional message attached to journal entries */
|
|
517
|
-
message?: string;
|
|
518
|
-
/** Non-interactive conflict strategy */
|
|
519
|
-
onConflict?: ConflictStrategy;
|
|
520
|
-
/**
|
|
521
|
-
* Vault service config — used when share() must resolve the entity and vend
|
|
522
|
-
* STS credentials itself (the default CLI path).
|
|
523
|
-
*
|
|
524
|
-
* Mutually exclusive with `entityContext`. Exactly one of the two must be
|
|
525
|
-
* provided; supplying both throws.
|
|
526
|
-
*/
|
|
527
|
-
vaultConfig?: VaultServiceConfig;
|
|
528
|
-
/**
|
|
529
|
-
* Pre-resolved entity context. When provided, share() skips its own
|
|
530
|
-
* `resolveEntityContext` call (no entity lookup, no STS vending) and uses
|
|
531
|
-
* these credentials directly.
|
|
532
|
-
*
|
|
533
|
-
* Use case: AppBar HQ Sync vends task-scoped creds via `/sts/vend-child`
|
|
534
|
-
* (preserving audit traceability via `task_id` + `task_description`)
|
|
535
|
-
* before invoking `hq sync push` as a subprocess. The subprocess reads the
|
|
536
|
-
* EntityContext JSON from stdin and passes it here.
|
|
537
|
-
*
|
|
538
|
-
* IMPORTANT: When using `entityContext`, the caller is responsible for
|
|
539
|
-
* vending credentials with enough TTL to cover the entire upload run.
|
|
540
|
-
* share() cannot auto-refresh a pre-vended context (it has no Cognito
|
|
541
|
-
* token to re-vend with) — if the credentials are expiring mid-run,
|
|
542
|
-
* share() throws a clear error rather than silently failing on the
|
|
543
|
-
* next S3 call.
|
|
544
|
-
*
|
|
545
|
-
* Mutually exclusive with `vaultConfig`.
|
|
546
|
-
*/
|
|
547
|
-
entityContext?: EntityContext;
|
|
548
|
-
/** HQ root directory */
|
|
549
|
-
hqRoot: string;
|
|
550
|
-
/**
|
|
551
|
-
* Per-file event callback. When present, suppresses the default
|
|
552
|
-
* `console.log`/`console.error` human output — same contract as `sync()`.
|
|
553
|
-
* This is the seam `hq-sync-runner` uses to stream ndjson for push events.
|
|
554
|
-
*/
|
|
555
|
-
onEvent?: (event: SyncProgressEvent) => void;
|
|
556
|
-
/**
|
|
557
|
-
* When true, files whose local hash matches the journal entry from the
|
|
558
|
-
* last sync are skipped (no remote HEAD, no upload). This is the gate
|
|
559
|
-
* that makes "push everything that changed" efficient — without it, a
|
|
560
|
-
* bidirectional Sync Now would re-upload every file each tick.
|
|
561
|
-
*
|
|
562
|
-
* Default false to preserve `hq share <file>` semantics: when a user
|
|
563
|
-
* explicitly names a file, they expect it to be sent even if the local
|
|
564
|
-
* hash matches the last-sync state (e.g. to re-heal a bucket).
|
|
565
|
-
*/
|
|
566
|
-
skipUnchanged?: boolean;
|
|
567
|
-
/**
|
|
568
|
-
* When true, journal entries whose local file is gone trigger a remote
|
|
569
|
-
* `DeleteObject`. Only entries whose key falls under one of the supplied
|
|
570
|
-
* `paths` (after resolution to absolute paths under `companies/{slug}/`)
|
|
571
|
-
* are considered, so `hq share <file>` can never sweep deletes outside
|
|
572
|
-
* the named scope.
|
|
573
|
-
*
|
|
574
|
-
* Vault buckets have versioning enabled, so the delete is soft: a
|
|
575
|
-
* delete-marker becomes the current version and prior object versions
|
|
576
|
-
* remain recoverable indefinitely. The pull-side `listRemoteFiles` skips
|
|
577
|
-
* objects whose current version is a delete-marker (default
|
|
578
|
-
* `ListObjectsV2` behavior), so a deletion stops the next pull from
|
|
579
|
-
* re-downloading the file on this and any other machine.
|
|
580
|
-
*
|
|
581
|
-
* Default false to preserve `hq share <file>` semantics — only the
|
|
582
|
-
* full-tree bidirectional runner opts in.
|
|
583
|
-
*/
|
|
584
|
-
propagateDeletes?: boolean;
|
|
585
|
-
/**
|
|
586
|
-
* Policy for which journal entries `propagateDeletes` is willing to
|
|
587
|
-
* convert into remote `DeleteObject` calls. Only consulted when
|
|
588
|
-
* `propagateDeletes === true`.
|
|
589
|
-
*
|
|
590
|
-
* - `"currency-gated"` (safest; default scheduled for 5.25 after soak):
|
|
591
|
-
* for each candidate, issue a remote HEAD and compare the current
|
|
592
|
-
* remote ETag against the journal's
|
|
593
|
-
* last-recorded `remoteEtag`. Match → safe-to-delete (this machine is
|
|
594
|
-
* current for the file, so the local deletion reflects an intentional
|
|
595
|
-
* removal AFTER seeing the latest remote version). Mismatch → refuse
|
|
596
|
-
* and emit `delete-refused-stale-etag`; the journal entry is left
|
|
597
|
-
* intact so the next pull leg re-pulls via the same hasRemoteChanged
|
|
598
|
-
* path. 404 → tombstone: drop the journal entry, no DeleteObject (the
|
|
599
|
-
* remote was already gone). Strictly safer than `owned-only` because
|
|
600
|
-
* it gates on per-file proof of currency rather than direction-of-
|
|
601
|
-
* origin — files that arrived via `/update-hq` (direction:"down") can
|
|
602
|
-
* legitimately be deleted by the device that pulled them, as long as
|
|
603
|
-
* no other device has touched them since.
|
|
604
|
-
* - `"owned-only"` (current default in 5.24): only entries whose journal
|
|
605
|
-
* `direction === "up"` are eligible. That is, only files this machine
|
|
606
|
-
* previously uploaded can be remotely deleted on its behalf. Entries
|
|
607
|
-
* recorded as pulled from elsewhere are never delete-propagated.
|
|
608
|
-
* Default in 5.24 while currency-gated soaks; scheduled to lose the
|
|
609
|
-
* default in 5.25. Downside: any file that arrived via `/update-hq`
|
|
610
|
-
* or another device's push is stuck on remote forever once locally
|
|
611
|
-
* removed, because no device "owns" it under this rule.
|
|
612
|
-
* - `"all"`: legacy behaviour — every in-scope journal entry whose
|
|
613
|
-
* local file is missing is eligible (regardless of direction or
|
|
614
|
-
* currency). The bidirectional runner's first-push and any tool that
|
|
615
|
-
* wants to mirror a destructive local checkout opts in here
|
|
616
|
-
* explicitly. Use with care — a stale device can erase peer uploads.
|
|
617
|
-
*
|
|
618
|
-
* Independently of this policy, an entry is also dropped from the plan
|
|
619
|
-
* when (a) it matches `EPHEMERAL_PATH_PATTERN` (conflict mirrors never
|
|
620
|
-
* propagate), or (b) neither the file-shape nor the directory-shape probe
|
|
621
|
-
* of `shouldSync` accepts the path — i.e. the current ignore filter would
|
|
622
|
-
* have skipped the path on pull. That symmetry blocks the failure mode
|
|
623
|
-
* where a path was filtered locally but lived in the vault (and the
|
|
624
|
-
* journal) from an older HQ layout or a different machine, causing the
|
|
625
|
-
* next push to erase it.
|
|
626
|
-
*/
|
|
627
|
-
propagateDeletePolicy?: "currency-gated" | "owned-only" | "all";
|
|
628
|
-
/**
|
|
629
|
-
* Explicit vault-relative delete roots captured by a live watcher before a
|
|
630
|
-
* scoped push. These roots do not need to exist locally; delete execution
|
|
631
|
-
* still requires a current version-bound intent (or server tombstone).
|
|
632
|
-
*/
|
|
633
|
-
deleteScopeRoots?: string[];
|
|
634
|
-
/**
|
|
635
|
-
* Hq-root-relative key prefixes whose journal entries should be
|
|
636
|
-
* unconditionally decommissioned from the remote bucket and journal,
|
|
637
|
-
* independent of whether the local file is present. Each prefix matches
|
|
638
|
-
* its exact-key form (e.g. "companies/foo") AND any descendant key
|
|
639
|
-
* (e.g. "companies/foo/knowledge/notes.md") — same prefix semantics as
|
|
640
|
-
* `propagateDeletes`'s scope roots.
|
|
641
|
-
*
|
|
642
|
-
* Use case: a company that previously synced to the operator's personal
|
|
643
|
-
* bucket has been promoted to its own team bucket (`/designate-team`).
|
|
644
|
-
* Its keys at `companies/{slug}/...` in the personal bucket are now
|
|
645
|
-
* orphans — the on-disk files still exist (the team bucket is the new
|
|
646
|
-
* canonical home), so the standard `propagateDeletes` gate ("local file
|
|
647
|
-
* missing") never fires. This option asserts "these objects no longer
|
|
648
|
-
* belong in THIS bucket regardless of local state" and uses the same
|
|
649
|
-
* DeleteObject + journal-removal path as `propagateDeletes`.
|
|
650
|
-
*
|
|
651
|
-
* Honors `propagateDeletePolicy`. Under `"owned-only"` (this function's
|
|
652
|
-
* own fallback when the caller passes nothing; every live caller passes
|
|
653
|
-
* `resolveDeletePolicy()`, which defaults to `"currency-gated"`) only
|
|
654
|
-
* journal entries with `direction === "up"` are decommissioned, so a
|
|
655
|
-
* misconfigured caller never erases content pulled from elsewhere.
|
|
656
|
-
*
|
|
657
|
-
* Independent of `propagateDeletes`: callers can opt into decommission
|
|
658
|
-
* without enabling general delete propagation. In practice the runner
|
|
659
|
-
* sets both for the personal slot.
|
|
660
|
-
*/
|
|
661
|
-
decommissionPrefixes?: string[];
|
|
662
|
-
/**
|
|
663
|
-
* Identity stamped onto each uploaded object's S3 user metadata
|
|
664
|
-
* (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
|
|
665
|
-
* reads `Metadata['created-by']` for its "CREATED BY" column; uploads
|
|
666
|
-
* without an author leave that column blank for every file synced via
|
|
667
|
-
* this engine. The runner pipes Cognito idToken claims through here.
|
|
668
|
-
*/
|
|
669
|
-
author?: UploadAuthor;
|
|
670
|
-
/**
|
|
671
|
-
* When true, share() targets the caller's person-entity bucket: syncRoot
|
|
672
|
-
* is `hqRoot` itself (NOT `hqRoot/companies/<slug>/`), so remote keys are
|
|
673
|
-
* hq-root-relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md") to
|
|
674
|
-
* match the Rust hq-sync first-push contract in
|
|
675
|
-
* `src-tauri/src/commands/personal.rs`. The exclusion of top-level dirs
|
|
676
|
-
* (.git, companies, core, data, personal, repos, workspace) is enforced
|
|
677
|
-
* by the runner — share() trusts its `paths` input.
|
|
678
|
-
*/
|
|
679
|
-
personalMode?: boolean;
|
|
680
|
-
/**
|
|
681
|
-
* Override for the per-slug journal file name. Defaults to `ctx.slug`. The
|
|
682
|
-
* runner passes `journalSlug: "personal"` for the personal slot so the TS
|
|
683
|
-
* push and the Rust personal first-push share idempotency state under one
|
|
684
|
-
* `sync-journal.personal.json` file.
|
|
685
|
-
*/
|
|
686
|
-
journalSlug?: string;
|
|
687
|
-
/**
|
|
688
|
-
* Effective ACL push scope as a list of company-relative prefixes (the same
|
|
689
|
-
* coalesced `prefixSet` the pull leg uses for `syncMode: "shared"`). When
|
|
690
|
-
* provided, the upload + delete plans are filtered to paths covered by these
|
|
691
|
-
* prefixes — any candidate outside them is skipped (and surfaced via a
|
|
692
|
-
* `scope-excluded` event) rather than PUT, because the vended child
|
|
693
|
-
* credential is scoped to exactly these prefixes and an out-of-scope PUT
|
|
694
|
-
* draws the server's correct 403 `SCOPE_EXCEEDS_PARENT`.
|
|
695
|
-
*
|
|
696
|
-
* `undefined` (the owner/`all` case, and `hq share <file>`) applies NO scope
|
|
697
|
-
* filter — full access. An empty array means "no granted prefixes" → every
|
|
698
|
-
* path is out of scope (mirrors the pull side's `isCoveredByAny([])`).
|
|
699
|
-
*/
|
|
700
|
-
prefixSet?: ScopePrefixInput[];
|
|
701
|
-
/**
|
|
702
|
-
* Pre-fetched FILE_TOMBSTONE map (POSIX key → tombstone) for the push-side
|
|
703
|
-
* delete-resync consult. When omitted, share() fetches it itself via
|
|
704
|
-
* `fetchCompanyTombstones` for COMPANY vaults that have a `vaultConfig`
|
|
705
|
-
* (personal vaults and pre-vended `entityContext`-only callers without a
|
|
706
|
-
* `vaultConfig` degrade to no-suppression — the safe, legacy direction).
|
|
707
|
-
*
|
|
708
|
-
* Injection is an optimization + test seam: a sync run that already fetched
|
|
709
|
-
* tombstones for the pull leg can hand the same map to the push leg to avoid a
|
|
710
|
-
* second round-trip, and tests can supply a controlled map without stubbing
|
|
711
|
-
* the network. See the consult in the Stage-2 classification pass.
|
|
712
|
-
*/
|
|
713
|
-
fileTombstones?: Map<string, CompanyTombstone>;
|
|
714
|
-
/**
|
|
715
|
-
* What to do when a path the caller EXPLICITLY named cannot be shipped —
|
|
716
|
-
* it does not resolve under any base, or it resolves outside the company
|
|
717
|
-
* folder (see `ShareResult.unreachablePaths`).
|
|
718
|
-
*
|
|
719
|
-
* - `"error"` (DEFAULT): a path that EXISTS on disk but resolves outside the
|
|
720
|
-
* company folder throws {@link UnreachablePushPathsError} BEFORE any upload
|
|
721
|
-
* runs, so the push is an atomic no-op and the CLI exits nonzero. This is
|
|
722
|
-
* ask #2 of feedback_a51cb63d — "error, not warn-skip, when the named file
|
|
723
|
-
* exists locally but is unreachable by the resolver". A user who typed a
|
|
724
|
-
* path and got "✓ Pushed 0 file(s)" had no way to know their content never
|
|
725
|
-
* left the machine. A path that resolves to NOTHING under any base is still
|
|
726
|
-
* only warn-recorded (see `collectFatalUnreachablePaths` for why bulk
|
|
727
|
-
* membership fanout depends on that).
|
|
728
|
-
* - `"warn"`: record it on the result + emit the `not-shipped` event and
|
|
729
|
-
* carry on, never throwing. For callers whose `paths` are INTERNAL walk
|
|
730
|
-
* roots rather than user input — the background sync runner — where a path
|
|
731
|
-
* disappearing mid-run is a benign race (a directory removed between the
|
|
732
|
-
* scan and the push) and must never fail an unattended sync.
|
|
733
|
-
*/
|
|
734
|
-
unreachablePathPolicy?: "error" | "warn";
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** Why an explicitly-named push path could not be shipped. */
|
|
738
|
-
export type UnreachablePathReason = "missing" | "outside-company" | "unreadable-link";
|
|
739
|
-
|
|
740
|
-
/**
|
|
741
|
-
* Thrown by `share()` when a caller-named path cannot be pushed and
|
|
742
|
-
* `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
|
|
743
|
-
* are still being built, so NOTHING has been uploaded, journaled, or deleted
|
|
744
|
-
* when it surfaces — the failed push leaves no partial state behind.
|
|
745
|
-
*/
|
|
746
|
-
export class UnreachablePushPathsError extends Error {
|
|
747
|
-
/** Caller's original spellings, verbatim (see the CollectHooks contract). */
|
|
748
|
-
readonly paths: string[];
|
|
749
|
-
/** Per-path reason, keyed by the same original spelling. */
|
|
750
|
-
readonly reasons: Record<string, UnreachablePathReason>;
|
|
751
|
-
|
|
752
|
-
constructor(unreachable: ReadonlyMap<string, UnreachablePathReason>, syncRoot: string) {
|
|
753
|
-
const paths = [...unreachable.keys()];
|
|
754
|
-
const lines = paths.map((p) => {
|
|
755
|
-
const reason = unreachable.get(p);
|
|
756
|
-
if (reason === "outside-company") {
|
|
757
|
-
return ` · ${p} — resolves outside the company folder (${syncRoot})`;
|
|
758
|
-
}
|
|
759
|
-
if (reason === "unreadable-link") {
|
|
760
|
-
return ` · ${p} — symbolic link target could not be read; it was not dereferenced or uploaded`;
|
|
761
|
-
}
|
|
762
|
-
return ` · ${p} — not found under the hq root, the company folder, or the current directory`;
|
|
763
|
-
});
|
|
764
|
-
super(
|
|
765
|
-
`${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
|
|
766
|
-
`nothing was uploaded.\n${lines.join("\n")}\n` +
|
|
767
|
-
`A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
|
|
768
|
-
`(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
|
|
769
|
-
`outside HQ has to sync through whatever owns it, not the vault.`,
|
|
770
|
-
);
|
|
771
|
-
this.name = "UnreachablePushPathsError";
|
|
772
|
-
this.paths = paths;
|
|
773
|
-
this.reasons = Object.fromEntries(unreachable);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
export interface ShareResult {
|
|
778
|
-
filesUploaded: number;
|
|
779
|
-
bytesUploaded: number;
|
|
780
|
-
filesSkipped: number;
|
|
781
|
-
/**
|
|
782
|
-
* Number of remote `DeleteObject` calls that succeeded this run. Always 0
|
|
783
|
-
* when `propagateDeletes` is false. The corresponding journal entries are
|
|
784
|
-
* removed in the same pass so the next sync sees the key as truly gone.
|
|
785
|
-
* Does NOT include tombstones (remote was already 404; no DELETE was
|
|
786
|
-
* issued — see `filesTombstoned`) or refused-stale entries (currency-
|
|
787
|
-
* gated refused because remote etag drifted — see `filesRefusedStale`).
|
|
788
|
-
*/
|
|
789
|
-
filesDeleted: number;
|
|
790
|
-
/**
|
|
791
|
-
* Number of journal entries dropped because the remote was already 404 at
|
|
792
|
-
* HEAD time (cleaned out-of-band — e.g. someone hand-deleted via the S3
|
|
793
|
-
* console, or another tool ran a destructive operation). No `DeleteObject`
|
|
794
|
-
* was issued for these; the journal converges with reality. Always 0 when
|
|
795
|
-
* `propagateDeletes` is false or `propagateDeletePolicy !== "currency-gated"`.
|
|
796
|
-
*/
|
|
797
|
-
filesTombstoned: number;
|
|
798
|
-
/**
|
|
799
|
-
* Number of delete candidates refused by the `currency-gated` policy
|
|
800
|
-
* because the remote object's current ETag no longer matches the journal's
|
|
801
|
-
* recorded one (some other device modified the file since this device last
|
|
802
|
-
* synced it) — OR because the journal entry is a legacy record with no
|
|
803
|
-
* `remoteEtag` to compare against. Neither S3 nor the journal is mutated
|
|
804
|
-
* for these; the next pull leg re-pulls naturally via `hasRemoteChanged`.
|
|
805
|
-
* Always 0 when `propagateDeletes` is false or policy is not
|
|
806
|
-
* `currency-gated`.
|
|
807
|
-
*/
|
|
808
|
-
filesRefusedStale: number;
|
|
809
|
-
/**
|
|
810
|
-
* Paths corresponding to `filesRefusedStale`, capped at 50 to keep the
|
|
811
|
-
* event payload bounded (mirrors `newFiles` capping). Surfaces *which*
|
|
812
|
-
* paths were refused so operators can triage the recurring
|
|
813
|
-
* \`filesRefusedStale: 205\` signal flagged in the 5.33.0 deep-test —
|
|
814
|
-
* the count alone is impossible to investigate because the per-file
|
|
815
|
-
* \`delete-refused-stale-etag\` events vanish from the event stream
|
|
816
|
-
* once the runner has folded them into the totals.
|
|
817
|
-
*/
|
|
818
|
-
filesRefusedStalePaths: string[];
|
|
819
|
-
/**
|
|
820
|
-
* Number of uploads suppressed by the push-side FILE_TOMBSTONE consult — keys
|
|
821
|
-
* an authoritative delete (`hq files delete`) tombstoned that this machine
|
|
822
|
-
* still held as the unchanged synced baseline. Skipping the upload is what
|
|
823
|
-
* stops a behind peer from resurrecting an authoritatively-deleted key. Always
|
|
824
|
-
* 0 when there are no tombstones for in-scope keys (the common case) or when
|
|
825
|
-
* the run can't load tombstones (personal vault / no `vaultConfig`).
|
|
826
|
-
*/
|
|
827
|
-
filesSuppressedByTombstone: number;
|
|
828
|
-
/**
|
|
829
|
-
* Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
|
|
830
|
-
* run (push leg, personalMode=true). Includes both files that would have
|
|
831
|
-
* uploaded and journal entries that would have been included in the delete
|
|
832
|
-
* plan; deduplicated across walks. Always 0 outside personalMode. Mirrors
|
|
833
|
-
* the `count` field of the `personal-vault-out-of-policy` event (which is
|
|
834
|
-
* emitted exactly once if this is > 0).
|
|
835
|
-
*/
|
|
836
|
-
filesExcludedByPolicy: number;
|
|
837
|
-
/**
|
|
838
|
-
* Number of distinct company-relative paths/prefixes skipped because they
|
|
839
|
-
* fell OUTSIDE the run's ACL `prefixSet` (member/guest scoped push). Always
|
|
840
|
-
* 0 when `prefixSet` is undefined (owner/`all`) or the whole tree is in
|
|
841
|
-
* scope. Mirrors the `count` field of the `scope-excluded` event (emitted
|
|
842
|
-
* once if this is > 0). These paths were never PUT, so the server's correct
|
|
843
|
-
* 403 `SCOPE_EXCEEDS_PARENT` is never triggered and the company still syncs
|
|
844
|
-
* its in-scope subset.
|
|
845
|
-
*/
|
|
846
|
-
filesExcludedByScope: number;
|
|
847
|
-
/**
|
|
848
|
-
* Number of distinct hq-root-relative paths skipped by the base ignore
|
|
849
|
-
* filter that look like real content rather than expected build/VCS/cache
|
|
850
|
-
* noise. Mirrors the `count` field of the `ignore-excluded` event (emitted
|
|
851
|
-
* once if this is > 0).
|
|
852
|
-
*/
|
|
853
|
-
filesExcludedByIgnore: number;
|
|
854
|
-
/**
|
|
855
|
-
* Paths the caller EXPLICITLY named for push that exist locally but the
|
|
856
|
-
* resolver could not place under the company folder (or could not find under
|
|
857
|
-
* any base). Empty in the common case (internal walk roots are always the
|
|
858
|
-
* reachable company folder). A non-empty list means the push did NOT ship
|
|
859
|
-
* something the caller asked for.
|
|
860
|
-
*
|
|
861
|
-
* Only ever non-empty under `unreachablePathPolicy: "warn"` — the DEFAULT
|
|
862
|
-
* `"error"` policy throws {@link UnreachablePushPathsError} instead of
|
|
863
|
-
* returning, so an interactive `hq sync push` fails loudly rather than
|
|
864
|
-
* reporting the pre-fix silent "Pushed 0 file(s)" success. This field is the
|
|
865
|
-
* warn-mode surface for unattended callers (sync runner / watcher).
|
|
866
|
-
*
|
|
867
|
-
* Entries are the caller's ORIGINAL spellings, verbatim — a relative token
|
|
868
|
-
* stays relative, an absolute token stays absolute — so the list is directly
|
|
869
|
-
* comparable to the `paths` input. See the CollectHooks spelling contract.
|
|
870
|
-
* Mirrors the `not-shipped` event with `reason: "unreachable-path"`.
|
|
871
|
-
*/
|
|
872
|
-
unreachablePaths: string[];
|
|
873
|
-
/**
|
|
874
|
-
* Company-relative keys of directory symlinks that were recorded as links but
|
|
875
|
-
* NOT descended because their target lives outside the company folder — their
|
|
876
|
-
* contents sync via their own repo, not the vault. Surfaced so files created
|
|
877
|
-
* under such a link (e.g. `companies/{co}/knowledge` → a linked repo) no
|
|
878
|
-
* longer vanish from every push bucket without a trace. Mirrors the
|
|
879
|
-
* `not-shipped` event with `reason: "linked-subtree"`.
|
|
880
|
-
*/
|
|
881
|
-
linkedSubtreesNotShipped: string[];
|
|
882
|
-
/**
|
|
883
|
-
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
884
|
-
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
885
|
-
* way to runner/UI consumers.
|
|
886
|
-
*/
|
|
887
|
-
conflictPaths: string[];
|
|
888
|
-
/** Uncapped per-path delete outcomes used by scoped watcher publication. */
|
|
889
|
-
pathResults?: SharePathResult[];
|
|
890
|
-
aborted: boolean;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
export type ShareDeleteRefusalReason =
|
|
894
|
-
| "stale-etag"
|
|
895
|
-
| "legacy-no-etag"
|
|
896
|
-
| "bulk-asymmetry"
|
|
897
|
-
| "divergent-local"
|
|
898
|
-
| "missing-delete-intent"
|
|
899
|
-
| "intent-changed"
|
|
900
|
-
| "recreated-locally"
|
|
901
|
-
| "transfer-error";
|
|
902
|
-
|
|
903
|
-
export interface SharePathResult {
|
|
904
|
-
path: string;
|
|
905
|
-
status: "accepted" | "refused";
|
|
906
|
-
operation: "delete" | "tombstone";
|
|
907
|
-
reason?: ShareDeleteRefusalReason;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* A conditional-write fence rejection — the SDK's 412 (`name:
|
|
912
|
-
* "PreconditionFailed"`) or the presigned transport's mirror of it. Means
|
|
913
|
-
* the remote moved past the etag this pass inspected (If-Match) or an
|
|
914
|
-
* object appeared at a key we believed absent (If-None-Match). Always a
|
|
915
|
-
* conflict, never a transport failure.
|
|
916
|
-
*/
|
|
917
|
-
function isPreconditionFailed(err: unknown): boolean {
|
|
918
|
-
if (err && typeof err === "object" && "name" in err) {
|
|
919
|
-
return (err as { name?: unknown }).name === "PreconditionFailed";
|
|
920
|
-
}
|
|
921
|
-
return false;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Wrap an existing path filter (ignore filter, optionally already wrapped with
|
|
926
|
-
* the personal-vault defaults) so that paths OUTSIDE the run's ACL `prefixSet`
|
|
927
|
-
* are rejected before they enter the upload OR delete plan. Keeps the
|
|
928
|
-
* `(absolutePath, isDir) => boolean` shape the rest of share() already uses
|
|
929
|
-
* (collectFiles / walkDir / computeDeletePlan), so wiring is one line at the
|
|
930
|
-
* filter-construction site — exactly like `wrapFilterWithPersonalVaultDefaults`.
|
|
931
|
-
*
|
|
932
|
-
* Files use `isCoveredByAny` (plain `startsWith`); directories use
|
|
933
|
-
* `isDirInScope` (descend if the dir is inside a grant OR a grant is inside
|
|
934
|
-
* the dir) so the walk still reaches deep/exact-file grants. Rejected entries
|
|
935
|
-
* are tagged via `onScopeExcluded` (directories with a trailing slash) so the
|
|
936
|
-
* runner can emit a single `scope-excluded` summary naming what was skipped.
|
|
937
|
-
*/
|
|
938
|
-
function wrapFilterWithScope(
|
|
939
|
-
underlying: (absPath: string, isDir?: boolean) => boolean,
|
|
940
|
-
syncRoot: string,
|
|
941
|
-
prefixSet: readonly ScopePrefixInput[],
|
|
942
|
-
onScopeExcluded: (rel: string) => void,
|
|
943
|
-
): (absPath: string, isDir?: boolean) => boolean {
|
|
944
|
-
return (absPath: string, isDir?: boolean) => {
|
|
945
|
-
if (!underlying(absPath, isDir)) return false;
|
|
946
|
-
const rel = vaultKeyForLocalPath(syncRoot, absPath);
|
|
947
|
-
if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
|
|
948
|
-
if (isDir) {
|
|
949
|
-
if (isDirInScope(rel, prefixSet)) return true;
|
|
950
|
-
onScopeExcluded(rel.endsWith("/") ? rel : rel + "/");
|
|
951
|
-
return false;
|
|
952
|
-
}
|
|
953
|
-
if (isCoveredByAny(rel, prefixSet)) return true;
|
|
954
|
-
onScopeExcluded(rel);
|
|
955
|
-
return false;
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
/**
|
|
960
|
-
* Wrap the base ignore filter so push can observe paths it rejects before
|
|
961
|
-
* personal-vault policy or ACL scope wrappers add their own exclusions.
|
|
962
|
-
* Every base-ignore rejection increments `onAnyExcluded`; only rejections
|
|
963
|
-
* that do NOT match expected build/VCS/cache noise are tagged through
|
|
964
|
-
* `onIgnoreExcluded` for the one-shot `ignore-excluded` summary.
|
|
965
|
-
*/
|
|
966
|
-
export function wrapFilterWithIgnoreVisibility(
|
|
967
|
-
underlying: (absPath: string, isDir?: boolean) => boolean,
|
|
968
|
-
hqRoot: string,
|
|
969
|
-
onIgnoreExcluded: (relPath: string) => void,
|
|
970
|
-
onAnyExcluded?: () => void,
|
|
971
|
-
): (absPath: string, isDir?: boolean) => boolean {
|
|
972
|
-
return (absPath: string, isDir?: boolean) => {
|
|
973
|
-
const allowed = underlying(absPath, isDir);
|
|
974
|
-
if (allowed) return true;
|
|
975
|
-
|
|
976
|
-
onAnyExcluded?.();
|
|
977
|
-
const rel = vaultKeyForLocalPath(hqRoot, absPath);
|
|
978
|
-
if (rel === "" || rel.startsWith("..")) return false;
|
|
979
|
-
if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
|
|
980
|
-
return false;
|
|
981
|
-
};
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
/**
|
|
985
|
-
* Share local file(s) to the entity vault.
|
|
986
|
-
*/
|
|
987
|
-
export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
988
|
-
const run = await createPushRunContext(options);
|
|
989
|
-
const counters = createShareCounters();
|
|
990
|
-
const filesRefusedStalePaths: string[] = [];
|
|
991
|
-
const conflictPaths: string[] = [];
|
|
992
|
-
const pathResults: SharePathResult[] = [];
|
|
993
|
-
|
|
994
|
-
const plans = await buildSharePlans(run);
|
|
995
|
-
const uploadResult = await executeUploads(
|
|
996
|
-
run,
|
|
997
|
-
plans.pushPlan,
|
|
998
|
-
counters,
|
|
999
|
-
conflictPaths,
|
|
1000
|
-
);
|
|
1001
|
-
if (uploadResult.aborted) {
|
|
1002
|
-
return buildShareResult(
|
|
1003
|
-
run,
|
|
1004
|
-
counters,
|
|
1005
|
-
filesRefusedStalePaths,
|
|
1006
|
-
uploadResult.abortFlightConflictPaths,
|
|
1007
|
-
pathResults,
|
|
1008
|
-
true,
|
|
1009
|
-
);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
await executeDeletes(
|
|
1013
|
-
run,
|
|
1014
|
-
plans.deletePlan,
|
|
1015
|
-
plans.decommissionPlan,
|
|
1016
|
-
counters,
|
|
1017
|
-
filesRefusedStalePaths,
|
|
1018
|
-
pathResults,
|
|
1019
|
-
);
|
|
1020
|
-
finalizeShareJournal(run);
|
|
1021
|
-
throwUploadWorkerErrors(uploadResult.workerErrors);
|
|
1022
|
-
|
|
1023
|
-
return buildShareResult(
|
|
1024
|
-
run,
|
|
1025
|
-
counters,
|
|
1026
|
-
filesRefusedStalePaths,
|
|
1027
|
-
conflictPaths,
|
|
1028
|
-
pathResults,
|
|
1029
|
-
false,
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
type DeletePolicy = NonNullable<ShareOptions["propagateDeletePolicy"]>;
|
|
1034
|
-
type ShareEmit = (event: SyncProgressEvent) => void;
|
|
1035
|
-
type UploadPlanItem = Extract<PushPlanItem, { action: "upload" }>;
|
|
1036
|
-
type JournalFileEntry = SyncJournal["files"][string];
|
|
1037
|
-
|
|
1038
|
-
interface PushRunContext {
|
|
1039
|
-
options: ShareOptions;
|
|
1040
|
-
paths: string[];
|
|
1041
|
-
message?: string;
|
|
1042
|
-
onConflict?: ConflictStrategy;
|
|
1043
|
-
vaultConfig?: VaultServiceConfig;
|
|
1044
|
-
entityContext?: EntityContext;
|
|
1045
|
-
hqRoot: string;
|
|
1046
|
-
skipUnchanged?: boolean;
|
|
1047
|
-
propagateDeletes?: boolean;
|
|
1048
|
-
propagateDeletePolicy: DeletePolicy;
|
|
1049
|
-
emit: ShareEmit;
|
|
1050
|
-
companyRef: string;
|
|
1051
|
-
ctx: EntityContext;
|
|
1052
|
-
syncRoot: string;
|
|
1053
|
-
shouldSync: (filePath: string, isDir?: boolean) => boolean;
|
|
1054
|
-
journalSlug: string;
|
|
1055
|
-
journal: SyncJournal;
|
|
1056
|
-
excludedSet: Set<string>;
|
|
1057
|
-
excludedById: Record<string, number>;
|
|
1058
|
-
scopeExcludedSet: Set<string>;
|
|
1059
|
-
ignoreExcludedSet: Set<string>;
|
|
1060
|
-
ignoreExcludedTotal: { value: number };
|
|
1061
|
-
/** Explicitly-named push paths that exist locally but the resolver could not
|
|
1062
|
-
* place under the company folder (or find at all), keyed by the CALLER'S
|
|
1063
|
-
* ORIGINAL spelling (see the CollectHooks spelling contract) and valued by
|
|
1064
|
-
* why it could not be shipped. Non-empty ⇒ the push did NOT ship something
|
|
1065
|
-
* the caller named — surfaced so a "Pushed 0 file(s)" is never a silent
|
|
1066
|
-
* false success, and (under the default `unreachablePathPolicy: "error"`)
|
|
1067
|
-
* raised as a hard failure before any upload runs. */
|
|
1068
|
-
unreachablePaths: Map<string, UnreachablePathReason>;
|
|
1069
|
-
/** Company-relative keys of directory symlinks recorded but not descended
|
|
1070
|
-
* because their target lives outside the company folder (contents sync via
|
|
1071
|
-
* their own repo, not the vault). */
|
|
1072
|
-
linkedSubtreeSet: Set<string>;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
interface ShareCounters {
|
|
1076
|
-
filesUploaded: number;
|
|
1077
|
-
bytesUploaded: number;
|
|
1078
|
-
filesSkipped: number;
|
|
1079
|
-
filesDeleted: number;
|
|
1080
|
-
filesTombstoned: number;
|
|
1081
|
-
filesRefusedStale: number;
|
|
1082
|
-
filesSuppressedByTombstone: number;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
interface SharePlans {
|
|
1086
|
-
pushPlan: PushPlan;
|
|
1087
|
-
deletePlan: DeletePlan;
|
|
1088
|
-
decommissionPlan: string[];
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
interface UploadExecutionResult {
|
|
1092
|
-
aborted: boolean;
|
|
1093
|
-
abortFlightConflictPaths: string[];
|
|
1094
|
-
workerErrors: Error[];
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
const REFUSED_STALE_PATH_CAP = 50;
|
|
1098
|
-
|
|
1099
|
-
async function createPushRunContext(options: ShareOptions): Promise<PushRunContext> {
|
|
1100
|
-
const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
|
|
1101
|
-
const propagateDeletePolicy: DeletePolicy =
|
|
1102
|
-
options.propagateDeletePolicy ?? "owned-only";
|
|
1103
|
-
const baseEmit = options.onEvent ?? defaultConsoleLogger;
|
|
1104
|
-
|
|
1105
|
-
if (vaultConfig && entityContext) {
|
|
1106
|
-
throw new Error(
|
|
1107
|
-
"share() requires exactly one of `vaultConfig` or `entityContext`, not both. " +
|
|
1108
|
-
"Pass `vaultConfig` to vend credentials internally, or `entityContext` to use pre-vended ones.",
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
if (!vaultConfig && !entityContext) {
|
|
1112
|
-
throw new Error(
|
|
1113
|
-
"share() requires either `vaultConfig` (for internal STS vending) " +
|
|
1114
|
-
"or `entityContext` (pre-vended credentials).",
|
|
1115
|
-
);
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
const companyRef =
|
|
1119
|
-
company ?? entityContext?.slug ?? resolveActiveCompany(hqRoot);
|
|
1120
|
-
if (!companyRef) {
|
|
1121
|
-
throw new Error(
|
|
1122
|
-
"No company specified and no active company found. " +
|
|
1123
|
-
"Use --company <slug> or set up .hq/config.json.",
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
const ctx: EntityContext = entityContext
|
|
1128
|
-
? entityContext
|
|
1129
|
-
: await resolveEntityContext(companyRef, vaultConfig!);
|
|
1130
|
-
|
|
1131
|
-
// NOTE (removed 6.14.48): a `prs_`-scoped coercion used to silently rewrite
|
|
1132
|
-
// `owned-only` -> `currency-gated` here (366721b). It was correct for the
|
|
1133
|
-
// problem it was written against and is now actively harmful, for three
|
|
1134
|
-
// reasons that only became true later:
|
|
1135
|
-
//
|
|
1136
|
-
// 1. Its ONLY reachable effect in production was to override an EXPLICIT
|
|
1137
|
-
// `owned-only`. Every live caller passes a policy
|
|
1138
|
-
// (`sync-runner-company.ts` -> `resolveDeletePolicy()`), and that
|
|
1139
|
-
// resolver already defaults to `currency-gated`. So the coercion never
|
|
1140
|
-
// fired on a default run — it fired exactly when an operator had set
|
|
1141
|
-
// `HQ_SYNC_DELETE_POLICY=owned-only`, i.e. the documented rollback.
|
|
1142
|
-
// 2. Once delete authorization moved to ETag currency (no watcher-minted
|
|
1143
|
-
// intent required), that override stopped being cosmetic: an operator
|
|
1144
|
-
// reaching for the rollback because deletes were misbehaving would keep
|
|
1145
|
-
// getting intent-less etag-only deletes on their personal vault. A
|
|
1146
|
-
// rollback knob that silently exempts a vault is not a rollback knob.
|
|
1147
|
-
// 3. Its motivating bug — the May-27 `personal/.obsidian/*.drift-*` leak —
|
|
1148
|
-
// is fixed twice over by mechanisms that postdate it: the vault-litter
|
|
1149
|
-
// drain (6.0.2) drains `.drift-*` markers ahead of every policy gate,
|
|
1150
|
-
// and etag-gated deletes propagate an intent-less `direction:"down"`
|
|
1151
|
-
// removal under the default policy without needing any coercion.
|
|
1152
|
-
//
|
|
1153
|
-
// So `owned-only` now means owned-only on every vault kind. It is the strict
|
|
1154
|
-
// rollback path and must stay strictly stricter than the default everywhere.
|
|
1155
|
-
|
|
1156
|
-
// Mirror push progress into the shared cross-process snapshot (see sync.ts).
|
|
1157
|
-
// Record the friendly company slug, or null for the personal vault so the
|
|
1158
|
-
// menubar shows "Personal" — never the raw prs_/cmp_ UID.
|
|
1159
|
-
const recordProgress = createSyncProgressRecorder({
|
|
1160
|
-
company:
|
|
1161
|
-
ctx.uid.startsWith("prs_") || options.personalMode === true
|
|
1162
|
-
? null
|
|
1163
|
-
: ctx.slug,
|
|
1164
|
-
phase: "push",
|
|
1165
|
-
});
|
|
1166
|
-
const emit: ShareEmit = (event) => {
|
|
1167
|
-
baseEmit(event);
|
|
1168
|
-
recordProgress(event);
|
|
1169
|
-
};
|
|
1170
|
-
|
|
1171
|
-
const syncRoot = options.personalMode === true
|
|
1172
|
-
? hqRoot
|
|
1173
|
-
: path.join(hqRoot, "companies", ctx.slug);
|
|
1174
|
-
|
|
1175
|
-
const ignoreFilter = createIgnoreFilter(hqRoot);
|
|
1176
|
-
const ignoreExcludedSet = new Set<string>();
|
|
1177
|
-
const ignoreExcludedTotal = { value: 0 };
|
|
1178
|
-
const recordedIgnoreFilter = wrapFilterWithIgnoreVisibility(
|
|
1179
|
-
ignoreFilter,
|
|
1180
|
-
hqRoot,
|
|
1181
|
-
(rel) => {
|
|
1182
|
-
ignoreExcludedSet.add(rel);
|
|
1183
|
-
},
|
|
1184
|
-
() => {
|
|
1185
|
-
ignoreExcludedTotal.value++;
|
|
1186
|
-
},
|
|
1187
|
-
);
|
|
1188
|
-
const excludedSet = new Set<string>();
|
|
1189
|
-
const excludedById: Record<string, number> = {};
|
|
1190
|
-
const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
|
|
1191
|
-
if (excludedSet.has(rel)) return;
|
|
1192
|
-
excludedSet.add(rel);
|
|
1193
|
-
excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
|
|
1194
|
-
};
|
|
1195
|
-
const scopeExcludedSet = new Set<string>();
|
|
1196
|
-
const onScopeExcluded = (rel: string) => {
|
|
1197
|
-
scopeExcludedSet.add(rel);
|
|
1198
|
-
};
|
|
1199
|
-
const unreachablePaths = new Map<string, UnreachablePathReason>();
|
|
1200
|
-
const linkedSubtreeSet = new Set<string>();
|
|
1201
|
-
const baseFilter = options.personalMode === true
|
|
1202
|
-
? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
|
|
1203
|
-
: recordedIgnoreFilter;
|
|
1204
|
-
const shouldSync = options.prefixSet !== undefined
|
|
1205
|
-
? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
|
|
1206
|
-
: baseFilter;
|
|
1207
|
-
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
1208
|
-
if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
|
|
1209
|
-
const journal = readJournal(journalSlug);
|
|
1210
|
-
|
|
1211
|
-
return {
|
|
1212
|
-
options,
|
|
1213
|
-
paths,
|
|
1214
|
-
message,
|
|
1215
|
-
onConflict,
|
|
1216
|
-
vaultConfig,
|
|
1217
|
-
entityContext,
|
|
1218
|
-
hqRoot,
|
|
1219
|
-
skipUnchanged,
|
|
1220
|
-
propagateDeletes,
|
|
1221
|
-
propagateDeletePolicy,
|
|
1222
|
-
emit,
|
|
1223
|
-
companyRef,
|
|
1224
|
-
ctx,
|
|
1225
|
-
syncRoot,
|
|
1226
|
-
shouldSync,
|
|
1227
|
-
journalSlug,
|
|
1228
|
-
journal,
|
|
1229
|
-
excludedSet,
|
|
1230
|
-
excludedById,
|
|
1231
|
-
scopeExcludedSet,
|
|
1232
|
-
ignoreExcludedSet,
|
|
1233
|
-
ignoreExcludedTotal,
|
|
1234
|
-
unreachablePaths,
|
|
1235
|
-
linkedSubtreeSet,
|
|
1236
|
-
};
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
function createShareCounters(): ShareCounters {
|
|
1240
|
-
return {
|
|
1241
|
-
filesUploaded: 0,
|
|
1242
|
-
bytesUploaded: 0,
|
|
1243
|
-
filesSkipped: 0,
|
|
1244
|
-
filesDeleted: 0,
|
|
1245
|
-
filesTombstoned: 0,
|
|
1246
|
-
filesRefusedStale: 0,
|
|
1247
|
-
filesSuppressedByTombstone: 0,
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
1252
|
-
const collected = collectFiles(
|
|
1253
|
-
run.paths,
|
|
1254
|
-
run.hqRoot,
|
|
1255
|
-
run.syncRoot,
|
|
1256
|
-
run.shouldSync,
|
|
1257
|
-
{
|
|
1258
|
-
onUnreachablePath: (namedPath, reason) => {
|
|
1259
|
-
// First reason wins: a path is named once, and re-adding would only
|
|
1260
|
-
// churn the map ordering the error message and event sample rely on.
|
|
1261
|
-
if (!run.unreachablePaths.has(namedPath)) {
|
|
1262
|
-
run.unreachablePaths.set(namedPath, reason);
|
|
1263
|
-
}
|
|
1264
|
-
},
|
|
1265
|
-
onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
|
|
1266
|
-
},
|
|
1267
|
-
);
|
|
1268
|
-
// Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
|
|
1269
|
-
// exists locally but is unreachable by the resolver". This is the throw that
|
|
1270
|
-
// makes it true end-to-end — the CLI's push handler already turns a thrown
|
|
1271
|
-
// error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
|
|
1272
|
-
// "✓ Pushed 0 file(s)" success can no longer happen for a file that is
|
|
1273
|
-
// sitting right there on disk. It fires HERE, before executeUploads, so a
|
|
1274
|
-
// failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
|
|
1275
|
-
// written, no delete propagated.
|
|
1276
|
-
const fatal = collectFatalUnreachablePaths(run);
|
|
1277
|
-
if (fatal.size > 0) {
|
|
1278
|
-
emitUnreachablePathEvent(run);
|
|
1279
|
-
throw new UnreachablePushPathsError(fatal, run.syncRoot);
|
|
1280
|
-
}
|
|
1281
|
-
// Control-character key filter (macOS Finder Icon\r incident). A key
|
|
1282
|
-
// containing a control character can never be stored — validateVaultUploadKey
|
|
1283
|
-
// rejects it with INVALID_KEY_CONTROL_CHARS — so planning an upload for one
|
|
1284
|
-
// only buys a guaranteed throw inside executeUploads, which lands in the
|
|
1285
|
-
// generic catch and emits `type: "error"`. That is the failure this filter
|
|
1286
|
-
// exists to stop: a non-empty errors[] makes the runner return
|
|
1287
|
-
// PARTIAL_SYNC_EXIT, so ONE such file on disk marks EVERY sync pass failed
|
|
1288
|
-
// forever. A macOS tree seeded with Finder `Icon\r` files produced 100+ per
|
|
1289
|
-
// run, wedging sync indefinitely.
|
|
1290
|
-
//
|
|
1291
|
-
// Structurally identical to the size-limit skip in executeUploads: a
|
|
1292
|
-
// permanently-invalid path is a benign skip, never an error. Symmetric with
|
|
1293
|
-
// the pull leg, which has classified such keys since the 2026-07-11 incident
|
|
1294
|
-
// (see computePullPlan's classifyVaultKey call). Applied before the mode
|
|
1295
|
-
// branch below so it covers personal and company vaults alike.
|
|
1296
|
-
//
|
|
1297
|
-
// Finder icon files are normally already gone by here — createIgnoreFilter
|
|
1298
|
-
// drops them during the walk. This stays as the general guard for any other
|
|
1299
|
-
// control-char path, and as defense for callers that bypass the walker.
|
|
1300
|
-
const controlCharFiltered = collected.filter((entry) => {
|
|
1301
|
-
if (!hasControlCharacters(entry.relativePath)) return true;
|
|
1302
|
-
run.emit({
|
|
1303
|
-
type: "skip-invalid-scoped-key",
|
|
1304
|
-
path: entry.relativePath,
|
|
1305
|
-
errorCode: "INVALID_KEY_CONTROL_CHARS",
|
|
1306
|
-
});
|
|
1307
|
-
return false;
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
|
-
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
1311
|
-
// root IS the company folder, so a local entry whose vault key starts with
|
|
1312
|
-
// `companies/` can only come from a stale doubled local tree
|
|
1313
|
-
// (companies/{slug}/companies/{slug}/…). Uploading it poisons the bucket
|
|
1314
|
-
// with keys the server validator rejects (INVALID_KEY_COMPANIES_SCOPED) —
|
|
1315
|
-
// the direct-S3 STS transport bypasses server validation. Refuse them here
|
|
1316
|
-
// with a per-key warning; uploadFile/uploadSymlink's validateVaultUploadKey
|
|
1317
|
-
// is the belt-and-suspenders backstop. The pull leg's journal-tombstone
|
|
1318
|
-
// path then CLEANS the doubled tree (local delete + journal drop) instead
|
|
1319
|
-
// of this leg ever re-uploading it. Personal vaults legitimately carry
|
|
1320
|
-
// `companies/{slug}/…` keys, so this only applies in company mode.
|
|
1321
|
-
const filesToShare =
|
|
1322
|
-
run.options.personalMode === true
|
|
1323
|
-
? controlCharFiltered.filter((entry) => {
|
|
1324
|
-
// `core/<type>` symlinks are generated projections of canonical
|
|
1325
|
-
// personal/package content. Reindex or package wiring owns their
|
|
1326
|
-
// lifecycle, so uploading them creates remote objects that a later
|
|
1327
|
-
// pull can only materialize temporarily before reindex removes them.
|
|
1328
|
-
// Keep ordinary core files eligible: only a symlink is derived.
|
|
1329
|
-
const generatedCoreMirror =
|
|
1330
|
-
entry.kind === "symlink" && isGeneratedCoreMirrorKey(entry.relativePath)
|
|
1331
|
-
if (!generatedCoreMirror) return true;
|
|
1332
|
-
|
|
1333
|
-
// This is a type-aware companion to the path-only personal-vault
|
|
1334
|
-
// default filters. `collectFiles` is the first seam that knows the
|
|
1335
|
-
// entry is a symlink, so record it here for the same summary/result
|
|
1336
|
-
// accounting used by the static exclusions.
|
|
1337
|
-
if (!run.excludedSet.has(entry.relativePath)) {
|
|
1338
|
-
run.excludedSet.add(entry.relativePath);
|
|
1339
|
-
run.excludedById["generated-core-mirror"] =
|
|
1340
|
-
(run.excludedById["generated-core-mirror"] ?? 0) + 1;
|
|
1341
|
-
}
|
|
1342
|
-
return false;
|
|
1343
|
-
})
|
|
1344
|
-
: controlCharFiltered.filter((entry) => {
|
|
1345
|
-
if (!entry.relativePath.startsWith("companies/")) return true;
|
|
1346
|
-
run.emit({
|
|
1347
|
-
type: "skip-invalid-scoped-key",
|
|
1348
|
-
path: entry.relativePath,
|
|
1349
|
-
});
|
|
1350
|
-
return false;
|
|
1351
|
-
});
|
|
1352
|
-
const pushPlan = computePushPlan(
|
|
1353
|
-
filesToShare,
|
|
1354
|
-
run.journal,
|
|
1355
|
-
run.skipUnchanged === true,
|
|
1356
|
-
);
|
|
1357
|
-
const deleteScopeRoots = run.propagateDeletes === true
|
|
1358
|
-
? resolveDeleteScopeRoots(
|
|
1359
|
-
run.paths,
|
|
1360
|
-
run.hqRoot,
|
|
1361
|
-
run.syncRoot,
|
|
1362
|
-
run.options.deleteScopeRoots,
|
|
1363
|
-
)
|
|
1364
|
-
: [];
|
|
1365
|
-
const deletePlan: DeletePlan = run.propagateDeletes === true
|
|
1366
|
-
? await computeDeletePlan(
|
|
1367
|
-
run.journal,
|
|
1368
|
-
run.syncRoot,
|
|
1369
|
-
deleteScopeRoots,
|
|
1370
|
-
run.shouldSync,
|
|
1371
|
-
run.propagateDeletePolicy,
|
|
1372
|
-
run.ctx,
|
|
1373
|
-
run.options.personalMode !== true,
|
|
1374
|
-
)
|
|
1375
|
-
: { toDelete: [], toTombstone: [], refusedStale: [] };
|
|
1376
|
-
const decommissionPlan =
|
|
1377
|
-
(run.options.decommissionPrefixes ?? []).length > 0
|
|
1378
|
-
? computeDecommissionPlan(
|
|
1379
|
-
run.journal,
|
|
1380
|
-
run.options.decommissionPrefixes ?? [],
|
|
1381
|
-
run.propagateDeletePolicy,
|
|
1382
|
-
new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]),
|
|
1383
|
-
)
|
|
1384
|
-
: [];
|
|
1385
|
-
|
|
1386
|
-
run.emit({
|
|
1387
|
-
type: "plan",
|
|
1388
|
-
filesToDownload: 0,
|
|
1389
|
-
bytesToDownload: 0,
|
|
1390
|
-
filesToUpload: pushPlan.filesToUpload,
|
|
1391
|
-
bytesToUpload: pushPlan.bytesToUpload,
|
|
1392
|
-
filesToSkip: pushPlan.filesToSkip,
|
|
1393
|
-
filesToConflict: 0,
|
|
1394
|
-
filesToDelete: deletePlan.toDelete.length + decommissionPlan.length,
|
|
1395
|
-
});
|
|
1396
|
-
|
|
1397
|
-
if (deletePlan.bulkAsymmetry) {
|
|
1398
|
-
run.emit({
|
|
1399
|
-
type: "delete-refused-bulk-asymmetry",
|
|
1400
|
-
candidates: deletePlan.bulkAsymmetry.candidates,
|
|
1401
|
-
inScope: deletePlan.bulkAsymmetry.inScope,
|
|
1402
|
-
ratio: deletePlan.bulkAsymmetry.ratio,
|
|
1403
|
-
samplePaths: deletePlan.bulkAsymmetry.samplePaths,
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
return { pushPlan, deletePlan, decommissionPlan };
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
async function executeUploads(
|
|
1411
|
-
run: PushRunContext,
|
|
1412
|
-
pushPlan: PushPlan,
|
|
1413
|
-
counters: ShareCounters,
|
|
1414
|
-
conflictPaths: string[],
|
|
1415
|
-
): Promise<UploadExecutionResult> {
|
|
1416
|
-
const TRANSFER_CONCURRENCY = resolveTransferConcurrency();
|
|
1417
|
-
let conflictPromptChain: Promise<unknown> = Promise.resolve();
|
|
1418
|
-
const resolveConflictSerialized = (
|
|
1419
|
-
info: Parameters<typeof resolveConflict>[0],
|
|
1420
|
-
): ReturnType<typeof resolveConflict> => {
|
|
1421
|
-
const resolution = conflictPromptChain.then(() =>
|
|
1422
|
-
resolveConflict(info, run.onConflict),
|
|
1423
|
-
);
|
|
1424
|
-
conflictPromptChain = resolution.then(
|
|
1425
|
-
() => undefined,
|
|
1426
|
-
() => undefined,
|
|
1427
|
-
);
|
|
1428
|
-
return resolution;
|
|
1429
|
-
};
|
|
1430
|
-
|
|
1431
|
-
const fileTombstones: Map<string, CompanyTombstone> =
|
|
1432
|
-
run.options.fileTombstones ??
|
|
1433
|
-
(!run.ctx.uid.startsWith("prs_") && run.vaultConfig
|
|
1434
|
-
? await fetchCompanyTombstones(run.vaultConfig, run.ctx.uid)
|
|
1435
|
-
: new Map<string, CompanyTombstone>());
|
|
1436
|
-
|
|
1437
|
-
const uploadItems: UploadPlanItem[] = [];
|
|
1438
|
-
for (const item of pushPlan.items) {
|
|
1439
|
-
if (item.action === "skip-size-limit") {
|
|
1440
|
-
// A file over the size cap is a permanent, benign skip — NOT an error.
|
|
1441
|
-
// Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
|
|
1442
|
-
// so every watch pass returned exit 2 and the menubar reported an
|
|
1443
|
-
// "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
|
|
1444
|
-
// tick (the HQ-SYNC-4 flood across user machines). Surface it for visibility, but
|
|
1445
|
-
// do not let it flip the pass to a non-zero exit.
|
|
1446
|
-
let bytes = 0;
|
|
1447
|
-
try {
|
|
1448
|
-
bytes = fs.statSync(item.absolutePath).size;
|
|
1449
|
-
} catch {
|
|
1450
|
-
// Best-effort size for display only; a stat race must not break the sync.
|
|
1451
|
-
}
|
|
1452
|
-
run.emit({
|
|
1453
|
-
type: "skip-size-limit",
|
|
1454
|
-
path: item.relativePath,
|
|
1455
|
-
bytes,
|
|
1456
|
-
});
|
|
1457
|
-
counters.filesSkipped++;
|
|
1458
|
-
continue;
|
|
1459
|
-
}
|
|
1460
|
-
if (item.action === "upload") {
|
|
1461
|
-
if (fileTombstones.size > 0) {
|
|
1462
|
-
const ts = fileTombstones.get(toPosixKey(item.relativePath));
|
|
1463
|
-
if (ts !== undefined) {
|
|
1464
|
-
const entry = run.journal.files[item.relativePath];
|
|
1465
|
-
if (entry && entry.hash === item.localHash) {
|
|
1466
|
-
counters.filesSuppressedByTombstone++;
|
|
1467
|
-
run.emit({
|
|
1468
|
-
type: "upload-suppressed-tombstone",
|
|
1469
|
-
path: item.relativePath,
|
|
1470
|
-
deletedAt: ts.deletedAt,
|
|
1471
|
-
});
|
|
1472
|
-
continue;
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
uploadItems.push(item);
|
|
1477
|
-
continue;
|
|
1478
|
-
}
|
|
1479
|
-
if (item.restamp) {
|
|
1480
|
-
const existing = run.journal.files[item.relativePath];
|
|
1481
|
-
if (existing && existing.hash) {
|
|
1482
|
-
existing.mtimeMs = item.restamp.mtimeMs;
|
|
1483
|
-
existing.size = item.restamp.size;
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
counters.filesSkipped++;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
await primeUploads(
|
|
1490
|
-
run.ctx,
|
|
1491
|
-
uploadItems.map((it) => ({
|
|
1492
|
-
key: it.relativePath,
|
|
1493
|
-
localPath: it.absolutePath,
|
|
1494
|
-
isSymlink: it.kind === "symlink",
|
|
1495
|
-
author: run.options.author,
|
|
1496
|
-
})),
|
|
1497
|
-
);
|
|
1498
|
-
// Warm the GET presigns the per-item conflict HEAD (remoteMeta) reuses, so a
|
|
1499
|
-
// large upload set doesn't mint one presign per HEAD and burst/trip the
|
|
1500
|
-
// presign breaker. Mirrors the new-files + tombstone pre-primes on the pull.
|
|
1501
|
-
await primeObjectTransport(
|
|
1502
|
-
run.ctx,
|
|
1503
|
-
"get",
|
|
1504
|
-
uploadItems.map((it) => it.relativePath),
|
|
1505
|
-
);
|
|
1506
|
-
|
|
1507
|
-
let aborted = false;
|
|
1508
|
-
let abortFlightConflictPaths: string[] = [];
|
|
1509
|
-
|
|
1510
|
-
const processUploadItem = async (item: UploadPlanItem): Promise<void> => {
|
|
1511
|
-
if (aborted) return;
|
|
1512
|
-
const { absolutePath, relativePath, localHash } = item;
|
|
1513
|
-
|
|
1514
|
-
// Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
|
|
1515
|
-
// ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
|
|
1516
|
-
// NEVER upload the local copy over them — not just on a two-sided conflict, but on
|
|
1517
|
-
// ANY divergence, including a one-sided local change. A stale local mirror (e.g. a
|
|
1518
|
-
// second HQ root that shares this machine's sync journal) would otherwise read as
|
|
1519
|
-
// `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
|
|
1520
|
-
// rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
|
|
1521
|
-
// board.json is the one push-side exception: local board appends are
|
|
1522
|
-
// legitimate, but must flow through the conditional PUT fence below. It
|
|
1523
|
-
// remains cloud-authoritative on pull so server automation still wins a
|
|
1524
|
-
// pull conflict.
|
|
1525
|
-
if (relativePath !== "board.json" && isCloudAuthoritative(relativePath)) {
|
|
1526
|
-
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1527
|
-
counters.filesSkipped++;
|
|
1528
|
-
return;
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
|
|
1532
|
-
run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
let remoteMeta: Awaited<ReturnType<typeof headRemoteFile>>;
|
|
1536
|
-
try {
|
|
1537
|
-
remoteMeta = await headRemoteFile(run.ctx, relativePath);
|
|
1538
|
-
} catch (headErr) {
|
|
1539
|
-
if (isAccessDenied(headErr)) {
|
|
1540
|
-
run.scopeExcludedSet.add(relativePath);
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
throw headErr;
|
|
1544
|
-
}
|
|
1545
|
-
// Journal entry for this key (if any). Used both for 3-way conflict
|
|
1546
|
-
// classification and for the write fence below: Prefer last-synced
|
|
1547
|
-
// `remoteEtag` over live HEAD so a peer-advanced object cannot be
|
|
1548
|
-
// silently LWW-overwritten by a journaled stale local body (Ace-shaped
|
|
1549
|
-
// vault regression 2026-08-03).
|
|
1550
|
-
const journalEntry = run.journal.files[relativePath];
|
|
1551
|
-
|
|
1552
|
-
if (remoteMeta) {
|
|
1553
|
-
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
1554
|
-
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
|
|
1555
|
-
|
|
1556
|
-
let isFreshCollision = false;
|
|
1557
|
-
let freshContentConverged = false;
|
|
1558
|
-
if (!journalEntry && item.kind === "symlink") {
|
|
1559
|
-
isFreshCollision = true;
|
|
1560
|
-
} else if (!journalEntry && item.kind === "file") {
|
|
1561
|
-
const remoteDiffers = await remoteContentDiffers(
|
|
1562
|
-
run.ctx,
|
|
1563
|
-
relativePath,
|
|
1564
|
-
localHash,
|
|
1565
|
-
run.hqRoot,
|
|
1566
|
-
);
|
|
1567
|
-
if (remoteDiffers) {
|
|
1568
|
-
isFreshCollision = true;
|
|
1569
|
-
} else {
|
|
1570
|
-
freshContentConverged = true;
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
if (freshContentConverged) {
|
|
1575
|
-
const lstat = fs.lstatSync(absolutePath);
|
|
1576
|
-
updateEntry(
|
|
1577
|
-
run.journal,
|
|
1578
|
-
relativePath,
|
|
1579
|
-
localHash,
|
|
1580
|
-
lstat.size,
|
|
1581
|
-
"up",
|
|
1582
|
-
absolutePath,
|
|
1583
|
-
{
|
|
1584
|
-
remoteEtag: remoteMeta.etag,
|
|
1585
|
-
mtimeMs: lstat.mtimeMs,
|
|
1586
|
-
kind: item.kind,
|
|
1587
|
-
},
|
|
1588
|
-
);
|
|
1589
|
-
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1590
|
-
counters.filesSkipped++;
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
// `localDiverges` means a prior pull-keep stamped the remote etag for
|
|
1595
|
-
// re-fire silence (#137) while local never matched that remote. The
|
|
1596
|
-
// journal-baseline If-Match fence alone would SUCCEED when remote is
|
|
1597
|
-
// still at that etag, silently promoting the divergent local to write
|
|
1598
|
-
// authority. Route through conflict (keep/abort/overwrite) instead.
|
|
1599
|
-
const divergentLocalHonesty = !!journalEntry?.localDiverges;
|
|
1600
|
-
|
|
1601
|
-
if ((localChanged && remoteChanged) || isFreshCollision || divergentLocalHonesty) {
|
|
1602
|
-
// Cloud-authoritative paths are already skipped at the top of
|
|
1603
|
-
// processUploadItem (before HEAD), so anything reaching here is a genuine
|
|
1604
|
-
// conflict on a client-owned file.
|
|
1605
|
-
conflictPaths.push(relativePath);
|
|
1606
|
-
|
|
1607
|
-
const resolution = await resolveConflictSerialized({
|
|
1608
|
-
path: relativePath,
|
|
1609
|
-
localHash,
|
|
1610
|
-
remoteModified: remoteMeta.lastModified,
|
|
1611
|
-
direction: "push",
|
|
1612
|
-
});
|
|
1613
|
-
|
|
1614
|
-
run.emit({
|
|
1615
|
-
type: "conflict",
|
|
1616
|
-
path: relativePath,
|
|
1617
|
-
direction: "push",
|
|
1618
|
-
resolution,
|
|
1619
|
-
});
|
|
1620
|
-
|
|
1621
|
-
if (resolution === "abort") {
|
|
1622
|
-
aborted = true;
|
|
1623
|
-
abortFlightConflictPaths = [...conflictPaths];
|
|
1624
|
-
return;
|
|
1625
|
-
}
|
|
1626
|
-
if (resolution === "keep" || resolution === "skip") {
|
|
1627
|
-
if (isFreshCollision) {
|
|
1628
|
-
await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag));
|
|
1629
|
-
}
|
|
1630
|
-
counters.filesSkipped++;
|
|
1631
|
-
return;
|
|
1632
|
-
}
|
|
1633
|
-
// overwrite: fall through to conditional PUT (or unfenced retry on 412).
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
if (aborted) return;
|
|
1638
|
-
|
|
1639
|
-
// Write fence: when remote exists, prefer last-synced journal remoteEtag
|
|
1640
|
-
// over live HEAD so a peer-advanced object fails closed (412 → keep/
|
|
1641
|
-
// abort/overwrite). Live-HEAD If-Match alone only covers HEAD→PUT TOCTOU.
|
|
1642
|
-
// When remote is missing (HEAD null), always create-fence with
|
|
1643
|
-
// If-None-Match:* — even if the journal still carries a stale remoteEtag
|
|
1644
|
-
// from a prior sync (deleted key recreation under keep). Using journal
|
|
1645
|
-
// If-Match against a missing object cannot succeed and false-conflicts.
|
|
1646
|
-
// Legacy entries without remoteEtag fall back to live HEAD (prior behavior).
|
|
1647
|
-
const precondition: PutPrecondition = remoteMeta
|
|
1648
|
-
? journalEntry?.remoteEtag
|
|
1649
|
-
? { ifMatch: journalEntry.remoteEtag }
|
|
1650
|
-
: { ifMatch: remoteMeta.etag }
|
|
1651
|
-
: { ifNoneMatch: "*" };
|
|
1652
|
-
|
|
1653
|
-
const performUpload = async (pc: PutPrecondition | undefined): Promise<void> => {
|
|
1654
|
-
const isSymlinkUpload = item.kind === "symlink";
|
|
1655
|
-
const lstat = fs.lstatSync(absolutePath);
|
|
1656
|
-
const size = isSymlinkUpload ? 0 : lstat.size;
|
|
1657
|
-
const mtimeMs = lstat.mtimeMs;
|
|
1658
|
-
|
|
1659
|
-
const { etag } = isSymlinkUpload
|
|
1660
|
-
? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
|
|
1661
|
-
: await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
|
|
1662
|
-
|
|
1663
|
-
updateEntry(
|
|
1664
|
-
run.journal,
|
|
1665
|
-
relativePath,
|
|
1666
|
-
localHash,
|
|
1667
|
-
size,
|
|
1668
|
-
"up",
|
|
1669
|
-
absolutePath,
|
|
1670
|
-
{ remoteEtag: etag, mtimeMs, kind: item.kind },
|
|
1671
|
-
);
|
|
1672
|
-
if (run.message) {
|
|
1673
|
-
run.journal.files[relativePath] = {
|
|
1674
|
-
...run.journal.files[relativePath],
|
|
1675
|
-
message: run.message,
|
|
1676
|
-
} as JournalFileEntry & { message: string };
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
counters.filesUploaded++;
|
|
1680
|
-
counters.bytesUploaded += size;
|
|
1681
|
-
run.emit({
|
|
1682
|
-
type: "progress",
|
|
1683
|
-
path: relativePath,
|
|
1684
|
-
bytes: size,
|
|
1685
|
-
...(run.message ? { message: run.message } : {}),
|
|
1686
|
-
});
|
|
1687
|
-
};
|
|
1688
|
-
|
|
1689
|
-
try {
|
|
1690
|
-
await performUpload(precondition);
|
|
1691
|
-
} catch (err) {
|
|
1692
|
-
if (err instanceof VaultAuthError) throw err;
|
|
1693
|
-
if (isPreconditionFailed(err)) {
|
|
1694
|
-
conflictPaths.push(relativePath);
|
|
1695
|
-
const resolution = await resolveConflictSerialized({
|
|
1696
|
-
path: relativePath,
|
|
1697
|
-
localHash,
|
|
1698
|
-
direction: "push",
|
|
1699
|
-
});
|
|
1700
|
-
run.emit({
|
|
1701
|
-
type: "conflict",
|
|
1702
|
-
path: relativePath,
|
|
1703
|
-
direction: "push",
|
|
1704
|
-
resolution,
|
|
1705
|
-
});
|
|
1706
|
-
if (resolution === "abort") {
|
|
1707
|
-
aborted = true;
|
|
1708
|
-
abortFlightConflictPaths = [...conflictPaths];
|
|
1709
|
-
return;
|
|
1710
|
-
}
|
|
1711
|
-
if (resolution === "overwrite") {
|
|
1712
|
-
try {
|
|
1713
|
-
await performUpload(undefined);
|
|
1714
|
-
} catch (retryErr) {
|
|
1715
|
-
if (retryErr instanceof VaultAuthError) throw retryErr;
|
|
1716
|
-
run.emit({
|
|
1717
|
-
type: "error",
|
|
1718
|
-
path: relativePath,
|
|
1719
|
-
message: describeError(retryErr),
|
|
1720
|
-
});
|
|
1721
|
-
}
|
|
1722
|
-
return;
|
|
1723
|
-
}
|
|
1724
|
-
await writePushConflictMirror(
|
|
1725
|
-
run,
|
|
1726
|
-
item,
|
|
1727
|
-
remoteMeta ? normalizeEtag(remoteMeta.etag) : "",
|
|
1728
|
-
);
|
|
1729
|
-
counters.filesSkipped++;
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
if (isAccessDenied(err)) {
|
|
1733
|
-
run.scopeExcludedSet.add(relativePath);
|
|
1734
|
-
return;
|
|
1735
|
-
}
|
|
1736
|
-
run.emit({
|
|
1737
|
-
type: "error",
|
|
1738
|
-
path: relativePath,
|
|
1739
|
-
message: describeError(err),
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
|
-
};
|
|
1743
|
-
|
|
1744
|
-
const workerErrors: Error[] = [];
|
|
1745
|
-
{
|
|
1746
|
-
const queue = [...uploadItems];
|
|
1747
|
-
const inFlight: Set<Promise<unknown>> = new Set();
|
|
1748
|
-
while (queue.length > 0 || inFlight.size > 0) {
|
|
1749
|
-
while (!aborted && inFlight.size < TRANSFER_CONCURRENCY && queue.length > 0) {
|
|
1750
|
-
const item = queue.shift()!;
|
|
1751
|
-
const p: Promise<void> = processUploadItem(item)
|
|
1752
|
-
.catch((err: unknown) => {
|
|
1753
|
-
workerErrors.push(err instanceof Error ? err : new Error(String(err)));
|
|
1754
|
-
})
|
|
1755
|
-
.finally(() => {
|
|
1756
|
-
inFlight.delete(p);
|
|
1757
|
-
});
|
|
1758
|
-
inFlight.add(p);
|
|
1759
|
-
}
|
|
1760
|
-
if (inFlight.size > 0) {
|
|
1761
|
-
await Promise.race(Array.from(inFlight));
|
|
1762
|
-
} else {
|
|
1763
|
-
break;
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
return { aborted, abortFlightConflictPaths, workerErrors };
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
async function writePushConflictMirror(
|
|
1772
|
-
run: PushRunContext,
|
|
1773
|
-
item: UploadPlanItem,
|
|
1774
|
-
remoteHash: string,
|
|
1775
|
-
): Promise<void> {
|
|
1776
|
-
try {
|
|
1777
|
-
const detectedAt = new Date().toISOString();
|
|
1778
|
-
const machineId = readShortMachineId(run.hqRoot);
|
|
1779
|
-
const originalRelative = vaultKeyForLocalPath(run.hqRoot, item.absolutePath);
|
|
1780
|
-
const conflictRelative = buildConflictPath(
|
|
1781
|
-
originalRelative,
|
|
1782
|
-
detectedAt,
|
|
1783
|
-
machineId,
|
|
1784
|
-
);
|
|
1785
|
-
const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
|
|
1786
|
-
if (!isMaterializationPathStillContained(run.syncRoot, conflictAbs)) {
|
|
1787
|
-
run.emit({
|
|
1788
|
-
type: "error",
|
|
1789
|
-
path: item.relativePath,
|
|
1790
|
-
message: "conflict mirror skipped: local parent escaped the sync root",
|
|
1791
|
-
});
|
|
1792
|
-
} else {
|
|
1793
|
-
await downloadFile(run.ctx, item.relativePath, conflictAbs);
|
|
1794
|
-
appendConflictEntry(run.hqRoot, {
|
|
1795
|
-
id: buildConflictId(originalRelative, detectedAt),
|
|
1796
|
-
originalPath: originalRelative,
|
|
1797
|
-
conflictPath: conflictRelative,
|
|
1798
|
-
detectedAt,
|
|
1799
|
-
side: "push",
|
|
1800
|
-
machineId,
|
|
1801
|
-
localHash: item.localHash,
|
|
1802
|
-
remoteHash,
|
|
1803
|
-
});
|
|
1804
|
-
}
|
|
1805
|
-
} catch (mirrorErr) {
|
|
1806
|
-
if (mirrorErr instanceof VaultAuthError) throw mirrorErr;
|
|
1807
|
-
run.emit({
|
|
1808
|
-
type: "error",
|
|
1809
|
-
path: item.relativePath,
|
|
1810
|
-
message: "conflict mirror write failed: " + describeError(mirrorErr),
|
|
1811
|
-
});
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
async function executeDeletes(
|
|
1816
|
-
run: PushRunContext,
|
|
1817
|
-
deletePlan: DeletePlan,
|
|
1818
|
-
decommissionPlan: string[],
|
|
1819
|
-
counters: ShareCounters,
|
|
1820
|
-
filesRefusedStalePaths: string[],
|
|
1821
|
-
pathResults: SharePathResult[],
|
|
1822
|
-
): Promise<void> {
|
|
1823
|
-
const deleteItems = [
|
|
1824
|
-
...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
|
|
1825
|
-
...decommissionPlan.map((key) => ({ key, intentVersion: null, decommission: true })),
|
|
1826
|
-
];
|
|
1827
|
-
const deleteKeys = deleteItems.map((item) => item.key);
|
|
1828
|
-
await primeObjectTransport(run.ctx, "delete", deleteKeys);
|
|
1829
|
-
for (const item of deleteItems) {
|
|
1830
|
-
const { key: relativePath } = item;
|
|
1831
|
-
if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
|
|
1832
|
-
run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
|
|
1833
|
-
}
|
|
1834
|
-
try {
|
|
1835
|
-
const entry = run.journal.files[relativePath];
|
|
1836
|
-
// Last-second absence re-check. Applies to EVERY propagated delete,
|
|
1837
|
-
// intent-backed or etag-only: a pull leg or the user can recreate the
|
|
1838
|
-
// file between planning and applying, and deleting it remotely then
|
|
1839
|
-
// destroys a file that exists locally right now. This used to sit inside
|
|
1840
|
-
// the intent-version check, which meant it was skipped for any item
|
|
1841
|
-
// carrying `intentVersion: null` — harmless while that only covered
|
|
1842
|
-
// litter and decommission, but not once etag-only candidates travel that
|
|
1843
|
-
// way too.
|
|
1844
|
-
//
|
|
1845
|
-
// Codec boundary: journal keys are canonical; the probe must use the
|
|
1846
|
-
// ENCODED local path or a win32 colon key always looks absent (a raw `:`
|
|
1847
|
-
// name can never exist on disk) and the remote delete proceeds while the
|
|
1848
|
-
// encoded local file is still present.
|
|
1849
|
-
const recreatedLocally =
|
|
1850
|
-
!item.decommission &&
|
|
1851
|
-
!isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath));
|
|
1852
|
-
const intentChanged =
|
|
1853
|
-
!item.decommission &&
|
|
1854
|
-
item.intentVersion !== null &&
|
|
1855
|
-
(!hasCurrentLocalDeleteIntent(entry) ||
|
|
1856
|
-
entry!.localDeleteIntent!.version !== item.intentVersion);
|
|
1857
|
-
if (recreatedLocally || intentChanged) {
|
|
1858
|
-
if (entry?.localDeleteIntent) {
|
|
1859
|
-
delete entry.localDeleteIntent;
|
|
1860
|
-
}
|
|
1861
|
-
const refusalReason = recreatedLocally
|
|
1862
|
-
? ("recreated-locally" as const)
|
|
1863
|
-
: ("intent-changed" as const);
|
|
1864
|
-
counters.filesRefusedStale++;
|
|
1865
|
-
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1866
|
-
filesRefusedStalePaths.push(relativePath);
|
|
1867
|
-
}
|
|
1868
|
-
run.emit({
|
|
1869
|
-
type: "delete-refused-stale-etag",
|
|
1870
|
-
path: relativePath,
|
|
1871
|
-
journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
|
|
1872
|
-
remoteEtag: "<not-checked>",
|
|
1873
|
-
reason: refusalReason,
|
|
1874
|
-
});
|
|
1875
|
-
pathResults.push({
|
|
1876
|
-
path: relativePath,
|
|
1877
|
-
status: "refused",
|
|
1878
|
-
operation: "delete",
|
|
1879
|
-
reason: refusalReason,
|
|
1880
|
-
});
|
|
1881
|
-
continue;
|
|
1882
|
-
}
|
|
1883
|
-
const size = entry?.size ?? 0;
|
|
1884
|
-
await deleteRemoteFile(run.ctx, relativePath);
|
|
1885
|
-
removeEntry(run.journal, relativePath);
|
|
1886
|
-
counters.filesDeleted++;
|
|
1887
|
-
run.emit({
|
|
1888
|
-
type: "progress",
|
|
1889
|
-
path: relativePath,
|
|
1890
|
-
bytes: size,
|
|
1891
|
-
deleted: true,
|
|
1892
|
-
});
|
|
1893
|
-
pathResults.push({
|
|
1894
|
-
path: relativePath,
|
|
1895
|
-
status: "accepted",
|
|
1896
|
-
operation: "delete",
|
|
1897
|
-
});
|
|
1898
|
-
} catch (err) {
|
|
1899
|
-
if (err instanceof VaultAuthError) throw err;
|
|
1900
|
-
run.emit({
|
|
1901
|
-
type: "error",
|
|
1902
|
-
path: relativePath,
|
|
1903
|
-
message: describeError(err),
|
|
1904
|
-
});
|
|
1905
|
-
pathResults.push({
|
|
1906
|
-
path: relativePath,
|
|
1907
|
-
status: "refused",
|
|
1908
|
-
operation: "delete",
|
|
1909
|
-
reason: "transfer-error",
|
|
1910
|
-
});
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
for (const relativePath of deletePlan.toTombstone) {
|
|
1914
|
-
const localPath = localPathForVaultKey(run.syncRoot, relativePath);
|
|
1915
|
-
try {
|
|
1916
|
-
const lstat = fs.lstatSync(localPath);
|
|
1917
|
-
const entry = run.journal.files[relativePath];
|
|
1918
|
-
if (lstat.isFile()) {
|
|
1919
|
-
const localHash = hashFile(localPath);
|
|
1920
|
-
if (entry?.hash && entry.hash !== localHash) {
|
|
1921
|
-
run.emit({
|
|
1922
|
-
type: "error",
|
|
1923
|
-
path: relativePath,
|
|
1924
|
-
message:
|
|
1925
|
-
"scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1926
|
-
});
|
|
1927
|
-
continue;
|
|
1928
|
-
}
|
|
1929
|
-
fs.unlinkSync(localPath);
|
|
1930
|
-
} else if (lstat.isSymbolicLink()) {
|
|
1931
|
-
const target = readlinkOrNull(localPath);
|
|
1932
|
-
if (target === null) {
|
|
1933
|
-
run.emit({
|
|
1934
|
-
type: "not-shipped",
|
|
1935
|
-
reason: "unreadable-link",
|
|
1936
|
-
count: 1,
|
|
1937
|
-
samplePaths: [relativePath],
|
|
1938
|
-
});
|
|
1939
|
-
continue;
|
|
1940
|
-
}
|
|
1941
|
-
const localHash = hashSymlinkTarget(target);
|
|
1942
|
-
if (entry?.hash && entry.hash !== localHash) {
|
|
1943
|
-
run.emit({
|
|
1944
|
-
type: "error",
|
|
1945
|
-
path: relativePath,
|
|
1946
|
-
message:
|
|
1947
|
-
"scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
|
|
1948
|
-
});
|
|
1949
|
-
continue;
|
|
1950
|
-
}
|
|
1951
|
-
fs.unlinkSync(localPath);
|
|
1952
|
-
}
|
|
1953
|
-
} catch (err: unknown) {
|
|
1954
|
-
const code =
|
|
1955
|
-
err && typeof err === "object" && "code" in err
|
|
1956
|
-
? (err as { code?: string }).code
|
|
1957
|
-
: undefined;
|
|
1958
|
-
if (code !== "ENOENT") {
|
|
1959
|
-
run.emit({
|
|
1960
|
-
type: "error",
|
|
1961
|
-
path: relativePath,
|
|
1962
|
-
message: `tombstone unlink failed: ${
|
|
1963
|
-
err instanceof Error ? err.message : String(err)
|
|
1964
|
-
}`,
|
|
1965
|
-
});
|
|
1966
|
-
continue;
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
removeEntry(run.journal, relativePath);
|
|
1970
|
-
counters.filesTombstoned++;
|
|
1971
|
-
run.emit({
|
|
1972
|
-
type: "progress",
|
|
1973
|
-
path: relativePath,
|
|
1974
|
-
bytes: 0,
|
|
1975
|
-
deleted: true,
|
|
1976
|
-
message: "tombstone (remote already 404)",
|
|
1977
|
-
});
|
|
1978
|
-
pathResults.push({
|
|
1979
|
-
path: relativePath,
|
|
1980
|
-
status: "accepted",
|
|
1981
|
-
operation: "tombstone",
|
|
1982
|
-
});
|
|
1983
|
-
}
|
|
1984
|
-
const decommissionedSet =
|
|
1985
|
-
decommissionPlan.length > 0 ? new Set(decommissionPlan) : null;
|
|
1986
|
-
for (const refused of deletePlan.refusedStale) {
|
|
1987
|
-
if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
|
|
1988
|
-
counters.filesRefusedStale++;
|
|
1989
|
-
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1990
|
-
filesRefusedStalePaths.push(refused.key);
|
|
1991
|
-
}
|
|
1992
|
-
run.emit({
|
|
1993
|
-
type: "delete-refused-stale-etag",
|
|
1994
|
-
path: refused.key,
|
|
1995
|
-
journalEtag: refused.journalEtag,
|
|
1996
|
-
remoteEtag: refused.remoteEtag,
|
|
1997
|
-
reason: refused.reason,
|
|
1998
|
-
});
|
|
1999
|
-
pathResults.push({
|
|
2000
|
-
path: refused.key,
|
|
2001
|
-
status: "refused",
|
|
2002
|
-
operation: "delete",
|
|
2003
|
-
reason: refused.reason,
|
|
2004
|
-
});
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
function finalizeShareJournal(run: PushRunContext): void {
|
|
2009
|
-
run.journal.lastSync = new Date().toISOString();
|
|
2010
|
-
writeJournal(run.journalSlug, run.journal);
|
|
2011
|
-
|
|
2012
|
-
if (run.excludedSet.size > 0) {
|
|
2013
|
-
const samplePaths: string[] = [];
|
|
2014
|
-
for (const p of run.excludedSet) {
|
|
2015
|
-
samplePaths.push(p);
|
|
2016
|
-
if (samplePaths.length >= 10) break;
|
|
2017
|
-
}
|
|
2018
|
-
run.emit({
|
|
2019
|
-
type: "personal-vault-out-of-policy",
|
|
2020
|
-
count: run.excludedSet.size,
|
|
2021
|
-
samplePaths,
|
|
2022
|
-
byId: { ...run.excludedById },
|
|
2023
|
-
});
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
if (run.scopeExcludedSet.size > 0) {
|
|
2027
|
-
const samplePaths: string[] = [];
|
|
2028
|
-
for (const p of run.scopeExcludedSet) {
|
|
2029
|
-
samplePaths.push(p);
|
|
2030
|
-
if (samplePaths.length >= 10) break;
|
|
2031
|
-
}
|
|
2032
|
-
run.emit({
|
|
2033
|
-
type: "scope-excluded",
|
|
2034
|
-
count: run.scopeExcludedSet.size,
|
|
2035
|
-
samplePaths,
|
|
2036
|
-
});
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
if (run.ignoreExcludedSet.size > 0) {
|
|
2040
|
-
const samplePaths: string[] = [];
|
|
2041
|
-
for (const p of run.ignoreExcludedSet) {
|
|
2042
|
-
samplePaths.push(p);
|
|
2043
|
-
if (samplePaths.length >= 10) break;
|
|
2044
|
-
}
|
|
2045
|
-
run.emit({
|
|
2046
|
-
type: "ignore-excluded",
|
|
2047
|
-
count: run.ignoreExcludedSet.size,
|
|
2048
|
-
totalExcluded: run.ignoreExcludedTotal.value,
|
|
2049
|
-
samplePaths,
|
|
2050
|
-
});
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
emitUnreachablePathEvent(run);
|
|
2054
|
-
|
|
2055
|
-
if (run.linkedSubtreeSet.size > 0) {
|
|
2056
|
-
run.emit({
|
|
2057
|
-
type: "not-shipped",
|
|
2058
|
-
reason: "linked-subtree",
|
|
2059
|
-
count: run.linkedSubtreeSet.size,
|
|
2060
|
-
samplePaths: sampleSet(run.linkedSubtreeSet),
|
|
2061
|
-
});
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
/**
|
|
2066
|
-
* Which unreachable named paths are FATAL under the run's policy.
|
|
2067
|
-
*
|
|
2068
|
-
* Under the default `"error"` policy only `"outside-company"` is fatal: the
|
|
2069
|
-
* entry is sitting on disk and the resolver refused to place it under the
|
|
2070
|
-
* company folder, which is precisely the "exists locally but is unreachable by
|
|
2071
|
-
* the resolver" case the report asks to turn into an error, and it cannot
|
|
2072
|
-
* happen for an internal walk root (a company folder is trivially inside
|
|
2073
|
-
* itself).
|
|
2074
|
-
*
|
|
2075
|
-
* `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
|
|
2076
|
-
* and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
|
|
2077
|
-
* one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
|
|
2078
|
-
* folder was never materialized locally; throwing there would turn "you haven't
|
|
2079
|
-
* pulled that company yet" into a hard failure of an unrelated multi-company
|
|
2080
|
-
* push. Same reasoning for a watcher path deleted between the event and the
|
|
2081
|
-
* push. Those callers get the loud report without the regression.
|
|
2082
|
-
*
|
|
2083
|
-
* `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
|
|
2084
|
-
* end to end (the background sync runner).
|
|
2085
|
-
*/
|
|
2086
|
-
function collectFatalUnreachablePaths(
|
|
2087
|
-
run: PushRunContext,
|
|
2088
|
-
): Map<string, UnreachablePathReason> {
|
|
2089
|
-
const fatal = new Map<string, UnreachablePathReason>();
|
|
2090
|
-
if (run.options.unreachablePathPolicy === "warn") return fatal;
|
|
2091
|
-
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
2092
|
-
if (reason === "outside-company") fatal.set(namedPath, reason);
|
|
2093
|
-
}
|
|
2094
|
-
return fatal;
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
/**
|
|
2098
|
-
* Emit the `not-shipped` / `unreachable-path` event for a run, if any named
|
|
2099
|
-
* path went unshipped. Shared by BOTH policies so the operator sees the same
|
|
2100
|
-
* report either way: the fail-fast path emits it immediately before throwing
|
|
2101
|
-
* (the journal finalizer never runs on a throw), and the `"warn"` path emits it
|
|
2102
|
-
* from the finalizer at the end of a successful run. Exactly one of those two
|
|
2103
|
-
* call sites can fire per run, so the event is never duplicated.
|
|
2104
|
-
*/
|
|
2105
|
-
function emitUnreachablePathEvent(run: PushRunContext): void {
|
|
2106
|
-
if (run.unreachablePaths.size === 0) return;
|
|
2107
|
-
const unreadableLinks: string[] = [];
|
|
2108
|
-
const unreachablePaths: string[] = [];
|
|
2109
|
-
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
2110
|
-
(reason === "unreadable-link" ? unreadableLinks : unreachablePaths).push(namedPath);
|
|
2111
|
-
}
|
|
2112
|
-
if (unreachablePaths.length > 0) {
|
|
2113
|
-
run.emit({
|
|
2114
|
-
type: "not-shipped",
|
|
2115
|
-
reason: "unreachable-path",
|
|
2116
|
-
count: unreachablePaths.length,
|
|
2117
|
-
samplePaths: unreachablePaths.slice(0, 10),
|
|
2118
|
-
});
|
|
2119
|
-
}
|
|
2120
|
-
if (unreadableLinks.length > 0) {
|
|
2121
|
-
run.emit({
|
|
2122
|
-
type: "not-shipped",
|
|
2123
|
-
reason: "unreadable-link",
|
|
2124
|
-
count: unreadableLinks.length,
|
|
2125
|
-
samplePaths: unreadableLinks.slice(0, 10),
|
|
2126
|
-
});
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
/** First up-to-`limit` members of an iterable, for bounded event payloads. */
|
|
2131
|
-
function sampleSet(set: Iterable<string>, limit = 10): string[] {
|
|
2132
|
-
const sample: string[] = [];
|
|
2133
|
-
for (const value of set) {
|
|
2134
|
-
sample.push(value);
|
|
2135
|
-
if (sample.length >= limit) break;
|
|
2136
|
-
}
|
|
2137
|
-
return sample;
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
function throwUploadWorkerErrors(workerErrors: Error[]): void {
|
|
2141
|
-
if (workerErrors.length > 0) {
|
|
2142
|
-
const first = workerErrors[0]!;
|
|
2143
|
-
if (workerErrors.length > 1) {
|
|
2144
|
-
first.message =
|
|
2145
|
-
first.message +
|
|
2146
|
-
" (and " +
|
|
2147
|
-
(workerErrors.length - 1) +
|
|
2148
|
-
" more upload-worker errors)";
|
|
2149
|
-
}
|
|
2150
|
-
throw first;
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
function buildShareResult(
|
|
2155
|
-
run: PushRunContext,
|
|
2156
|
-
counters: ShareCounters,
|
|
2157
|
-
filesRefusedStalePaths: string[],
|
|
2158
|
-
conflictPaths: string[],
|
|
2159
|
-
pathResults: SharePathResult[],
|
|
2160
|
-
aborted: boolean,
|
|
2161
|
-
): ShareResult {
|
|
2162
|
-
return {
|
|
2163
|
-
filesUploaded: counters.filesUploaded,
|
|
2164
|
-
bytesUploaded: counters.bytesUploaded,
|
|
2165
|
-
filesSkipped: counters.filesSkipped,
|
|
2166
|
-
filesDeleted: counters.filesDeleted,
|
|
2167
|
-
filesTombstoned: counters.filesTombstoned,
|
|
2168
|
-
filesRefusedStale: counters.filesRefusedStale,
|
|
2169
|
-
filesRefusedStalePaths,
|
|
2170
|
-
filesSuppressedByTombstone: counters.filesSuppressedByTombstone,
|
|
2171
|
-
filesExcludedByPolicy: run.excludedSet.size,
|
|
2172
|
-
filesExcludedByScope: run.scopeExcludedSet.size,
|
|
2173
|
-
filesExcludedByIgnore: run.ignoreExcludedSet.size,
|
|
2174
|
-
unreachablePaths: [...run.unreachablePaths.keys()],
|
|
2175
|
-
linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
|
|
2176
|
-
conflictPaths,
|
|
2177
|
-
pathResults,
|
|
2178
|
-
aborted,
|
|
2179
|
-
};
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
/**
|
|
2183
|
-
* Default human-readable share output. Preserves the exact format the CLI
|
|
2184
|
-
* emitted before `onEvent` was added — tty users see no change.
|
|
2185
|
-
*/
|
|
2186
|
-
function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
2187
|
-
if (event.type === "plan") {
|
|
2188
|
-
if (event.filesToUpload > 0) {
|
|
2189
|
-
console.log(
|
|
2190
|
-
`Plan: ${event.filesToUpload} to upload (${event.bytesToUpload} bytes), ${event.filesToSkip} unchanged`,
|
|
2191
|
-
);
|
|
2192
|
-
}
|
|
2193
|
-
} else if (event.type === "progress") {
|
|
2194
|
-
if (event.deleted) {
|
|
2195
|
-
// Append `message` when present (e.g. tombstone events carry
|
|
2196
|
-
// "tombstone (remote already 404)"). Without this, tombstones and
|
|
2197
|
-
// real deletes render byte-identically in the tty stream, and
|
|
2198
|
-
// operators have no way to distinguish from logs alone.
|
|
2199
|
-
const suffix = event.message ? ` — ${event.message}` : "";
|
|
2200
|
-
console.log(` ✗ ${event.path} (deleted)${suffix}`);
|
|
2201
|
-
} else if (event.message) {
|
|
2202
|
-
console.log(` ✓ ${event.path} — "${event.message}"`);
|
|
2203
|
-
} else {
|
|
2204
|
-
console.log(` ✓ ${event.path}`);
|
|
2205
|
-
}
|
|
2206
|
-
} else if (event.type === "conflict") {
|
|
2207
|
-
console.error(
|
|
2208
|
-
` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
|
|
2209
|
-
);
|
|
2210
|
-
} else if (event.type === "error") {
|
|
2211
|
-
console.error(` ✗ ${event.path} — ${event.message}`);
|
|
2212
|
-
} else if (event.type === "delete-refused-stale-etag") {
|
|
2213
|
-
// Branch on `reason`, not on the sentinel etag strings, so legacy
|
|
2214
|
-
// entries render with a clear explanation instead of "<legacy-no-etag>"
|
|
2215
|
-
// leaking into operator-visible output.
|
|
2216
|
-
if (event.reason === "legacy-no-etag") {
|
|
2217
|
-
console.error(
|
|
2218
|
-
` ⚠ no-etag-on-record, kept on remote: ${event.path} (journal entry predates etag tracking)`,
|
|
2219
|
-
);
|
|
2220
|
-
} else if (event.reason === "bulk-asymmetry") {
|
|
2221
|
-
// The one-shot summary below carries the count + bypass instructions.
|
|
2222
|
-
// Per-key lines stay compact so a 100-candidate refusal doesn't bury
|
|
2223
|
-
// the summary banner.
|
|
2224
|
-
console.error(` ⚠ bulk-asymmetry refusal, kept on remote: ${event.path}`);
|
|
2225
|
-
} else if (event.reason === "missing-delete-intent") {
|
|
2226
|
-
// NEITHER etag is meaningful here: no HEAD was issued, so `remoteEtag`
|
|
2227
|
-
// is the `<not-checked>` sentinel, and `journalEtag` is the entry's
|
|
2228
|
-
// real recorded etag. Printing them under the `stale-etag` wording
|
|
2229
|
-
// (which this branch used to fall through to) reads as an etag
|
|
2230
|
-
// conflict and has repeatedly sent operators chasing a currency bug
|
|
2231
|
-
// that does not exist — the actual cause is that no watcher observed
|
|
2232
|
-
// the removal, so no delete authorization was ever recorded.
|
|
2233
|
-
console.error(
|
|
2234
|
-
` ⚠ no-delete-authorization, kept on remote: ${event.path} ` +
|
|
2235
|
-
`(the sync watcher did not observe this removal, so the delete was never authorized)`,
|
|
2236
|
-
);
|
|
2237
|
-
} else if (event.reason === "recreated-locally") {
|
|
2238
|
-
console.error(
|
|
2239
|
-
` ⚠ recreated-locally, kept on remote: ${event.path} ` +
|
|
2240
|
-
`(the file exists locally again, so the delete was abandoned)`,
|
|
2241
|
-
);
|
|
2242
|
-
} else if (event.reason === "divergent-local") {
|
|
2243
|
-
// Same trap as above — `remoteEtag` is `<not-checked>`. The entry's
|
|
2244
|
-
// recorded etag was stamped only to silence a re-fired conflict; the
|
|
2245
|
-
// local copy never matched it, so propagating the delete would destroy
|
|
2246
|
-
// divergent remote work.
|
|
2247
|
-
console.error(
|
|
2248
|
-
` ⚠ divergent-local, kept on remote: ${event.path} ` +
|
|
2249
|
-
`(the local copy never matched the recorded remote version; pull to reconcile first)`,
|
|
2250
|
-
);
|
|
2251
|
-
} else {
|
|
2252
|
-
console.error(
|
|
2253
|
-
` ⚠ stale-etag, kept on remote: ${event.path} (journal=${event.journalEtag}, remote=${event.remoteEtag})`,
|
|
2254
|
-
);
|
|
2255
|
-
}
|
|
2256
|
-
} else if (event.type === "delete-refused-bulk-asymmetry") {
|
|
2257
|
-
const pct = (event.ratio * 100).toFixed(1);
|
|
2258
|
-
console.error(
|
|
2259
|
-
` ⚠ bulk-asymmetry circuit-breaker tripped: ${event.candidates}/${event.inScope} in-scope journal entries (${pct}%) are missing locally.\n` +
|
|
2260
|
-
` Refusing to propagate deletes — this looks like a local-mirror loss (moved hqRoot, partial restore, fresh clone, unmounted volume, accidental rm) rather than an intentional cleanup.\n` +
|
|
2261
|
-
` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
|
|
2262
|
-
` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
|
|
2263
|
-
);
|
|
2264
|
-
} else if (event.type === "skip-invalid-scoped-key") {
|
|
2265
|
-
console.warn(
|
|
2266
|
-
` ! ${event.path} skipped — 'companies/'-prefixed key is invalid in a company-scoped vault (stale doubled local tree? remove the inner companies/<slug> copy)`,
|
|
2267
|
-
);
|
|
2268
|
-
} else if (event.type === "ignore-excluded") {
|
|
2269
|
-
// The "not silent" surface: an ignore rule dropped content that doesn't
|
|
2270
|
-
// look like expected build/VCS/cache noise, so it never reached the vault.
|
|
2271
|
-
// Surface it as a WARN naming the paths so an over-broad exclusion is
|
|
2272
|
-
// observable rather than invisible (DEV-1791 / feedback_b9dfc7ae).
|
|
2273
|
-
console.warn(
|
|
2274
|
-
` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
|
|
2275
|
-
);
|
|
2276
|
-
for (const p of event.samplePaths) {
|
|
2277
|
-
console.warn(` · ${p}`);
|
|
2278
|
-
}
|
|
2279
|
-
if (event.count > event.samplePaths.length) {
|
|
2280
|
-
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
2281
|
-
}
|
|
2282
|
-
} else if (event.type === "not-shipped") {
|
|
2283
|
-
// The other "not silent" surface: content the walk saw but chose not to
|
|
2284
|
-
// ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
|
|
2285
|
-
if (event.reason === "unreachable-path") {
|
|
2286
|
-
console.warn(
|
|
2287
|
-
` ! ${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"}):`,
|
|
2288
|
-
);
|
|
2289
|
-
} else if (event.reason === "unreadable-link") {
|
|
2290
|
-
console.warn(
|
|
2291
|
-
` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
|
|
2292
|
-
);
|
|
2293
|
-
} else {
|
|
2294
|
-
console.warn(
|
|
2295
|
-
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
2296
|
-
);
|
|
2297
|
-
}
|
|
2298
|
-
for (const p of event.samplePaths) {
|
|
2299
|
-
console.warn(` · ${p}`);
|
|
2300
|
-
}
|
|
2301
|
-
if (event.count > event.samplePaths.length) {
|
|
2302
|
-
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
/**
|
|
2308
|
-
* One entry produced by collectFiles/walkDir. Files describe regular
|
|
2309
|
-
* payloads that get hashed + size-checked + uploaded via uploadFile;
|
|
2310
|
-
* symlinks describe link records whose target string flows through
|
|
2311
|
-
* uploadSymlink as user metadata. Walked-dir traversal NEVER descends
|
|
2312
|
-
* into a symlink — directory symlinks are recorded as link entries and
|
|
2313
|
-
* left at that, so following them never duplicates content into the
|
|
2314
|
-
* wrong vault path (the same topology safety the legacy walker provided
|
|
2315
|
-
* by accident of Dirent.isFile() returning false for links).
|
|
2316
|
-
*/
|
|
2317
|
-
type CollectedEntry =
|
|
2318
|
-
| { kind: "file"; absolutePath: string; relativePath: string }
|
|
2319
|
-
| { kind: "symlink"; absolutePath: string; relativePath: string; target: string };
|
|
2320
|
-
|
|
2321
|
-
/**
|
|
2322
|
-
* Optional visibility callbacks for {@link collectFiles} / {@link walkDir}.
|
|
2323
|
-
* They turn two previously-SILENT outcomes into surfaced signals — without
|
|
2324
|
-
* changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
|
|
2325
|
-
*
|
|
2326
|
-
* - `onUnreachablePath`: a path that cannot be shipped. It is normally the
|
|
2327
|
-
* caller's explicit spelling (`"outside-company"` or `"missing"`), and is
|
|
2328
|
-
* the company-relative key for an unreadable link discovered during a
|
|
2329
|
-
* directory walk (`"unreadable-link"`). Pre-fix this was a bare
|
|
2330
|
-
* `console.error` warn-skip that still let the push report "Pushed 0 file(s)"
|
|
2331
|
-
* — a false success. The caller can now turn a non-empty set into a real
|
|
2332
|
-
* error / nonzero exit.
|
|
2333
|
-
*
|
|
2334
|
-
* SPELLING CONTRACT: resolver outcomes use the caller's ORIGINAL token,
|
|
2335
|
-
* verbatim — never the resolved absolute path, and never normalized. A
|
|
2336
|
-
* relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
|
|
2337
|
-
* an absolute path is reported absolute because that is what was passed.
|
|
2338
|
-
* An unreadable link discovered during a recursive walk instead uses its
|
|
2339
|
-
* company-relative vault key, because it has no separate caller token.
|
|
2340
|
-
* This keeps UI output actionable without exposing host-specific paths.
|
|
2341
|
-
* - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
|
|
2342
|
-
* descended because its target resolves OUTSIDE the company folder (e.g.
|
|
2343
|
-
* `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
|
|
2344
|
-
* contents ship via their own repo, not the vault; reporting it stops files
|
|
2345
|
-
* created under such a link from vanishing from every push bucket silently.
|
|
2346
|
-
*/
|
|
2347
|
-
interface CollectHooks {
|
|
2348
|
-
onUnreachablePath?: (namedPath: string, reason: UnreachablePathReason) => void;
|
|
2349
|
-
onLinkedSubtree?: (rel: string) => void;
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
/**
|
|
2353
|
-
* Resolve a caller-supplied push path to an absolute path.
|
|
2354
|
-
*
|
|
2355
|
-
* Relative paths were historically resolved against `hqRoot` ONLY, so a
|
|
2356
|
-
* company-relative spelling like `knowledge/agents/x.md` became
|
|
2357
|
-
* `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
|
|
2358
|
-
* caller's cwd or the company being pushed (feedback_258e4a86 /
|
|
2359
|
-
* feedback_a51cb63d — "there is no path spelling that reaches the file").
|
|
2360
|
-
*
|
|
2361
|
-
* PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
|
|
2362
|
-
* (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
|
|
2363
|
-
* ADDITIVE to the legacy behavior: every relative spelling that resolved
|
|
2364
|
-
* pre-fix still resolves to exactly the same file, and the two new bases only
|
|
2365
|
-
* catch spellings that previously resolved to nothing. Probing cwd first would
|
|
2366
|
-
* silently re-point existing callers (a `knowledge/` directory in the shell's
|
|
2367
|
-
* cwd would win over the hq-root one), which is a behavior change no reporter
|
|
2368
|
-
* asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
|
|
2369
|
-
* the unchanged "does not exist" diagnostic. Absolute paths are returned
|
|
2370
|
-
* verbatim.
|
|
2371
|
-
*
|
|
2372
|
-
* `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
|
|
2373
|
-
* rather than an ambient read, so resolution is deterministic and testable
|
|
2374
|
-
* without mutating process state.
|
|
2375
|
-
*/
|
|
2376
|
-
function resolveNamedPath(
|
|
2377
|
-
p: string,
|
|
2378
|
-
hqRoot: string,
|
|
2379
|
-
syncRoot: string,
|
|
2380
|
-
cwd: string = process.cwd(),
|
|
2381
|
-
): string {
|
|
2382
|
-
if (path.isAbsolute(p)) return p;
|
|
2383
|
-
const hqRootCandidate = path.resolve(hqRoot, p);
|
|
2384
|
-
const candidates = [
|
|
2385
|
-
hqRootCandidate,
|
|
2386
|
-
path.resolve(syncRoot, p),
|
|
2387
|
-
path.resolve(cwd, p),
|
|
2388
|
-
];
|
|
2389
|
-
for (const candidate of candidates) {
|
|
2390
|
-
try {
|
|
2391
|
-
fs.lstatSync(candidate);
|
|
2392
|
-
// An hqRoot hit outside the company folder would be rejected by
|
|
2393
|
-
// collectFiles as outside-company; skip it so a valid company-relative
|
|
2394
|
-
// spelling can win (Codex P2 — common `knowledge/` homonym case).
|
|
2395
|
-
if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
|
|
2396
|
-
continue;
|
|
2397
|
-
}
|
|
2398
|
-
return candidate;
|
|
2399
|
-
} catch {
|
|
2400
|
-
// Base did not resolve to an on-disk entry — try the next one.
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
return hqRootCandidate;
|
|
2404
|
-
}
|
|
2405
|
-
|
|
2406
|
-
/**
|
|
2407
|
-
* Containment check for a regular file or directory that tolerates a symlinked
|
|
2408
|
-
* ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
|
|
2409
|
-
* path reached through a symlinked ancestor (`companies/{co}/knowledge` →
|
|
2410
|
-
* `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
|
|
2411
|
-
* rejected as "outside company folder" — even though its logical path is
|
|
2412
|
-
* in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
|
|
2413
|
-
* company-namespaced key for it. Accept when the LEXICAL path is inside
|
|
2414
|
-
* (honoring the same logical topology the vault key uses) OR the realpath is
|
|
2415
|
-
* inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
|
|
2416
|
-
*
|
|
2417
|
-
* The lexical arm carries TWO bounds, because it is the only place where a
|
|
2418
|
-
* path's bytes and its vault key come from different trees:
|
|
2419
|
-
*
|
|
2420
|
-
* 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
|
|
2421
|
-
* symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
|
|
2422
|
-
* state under a company-namespaced key.
|
|
2423
|
-
* 2. The TENANT — the realpath must not land inside another company's bytes
|
|
2424
|
-
* (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
|
|
2425
|
-
* must never be mistaken for a tenant boundary: hqRoot CONTAINS every
|
|
2426
|
-
* other company, so `companies/acme/knowledge → companies/other/secret`
|
|
2427
|
-
* (or → `repos/private/knowledge-other`, the linked-repo topology) is
|
|
2428
|
-
* lexically inside acme and really inside HQ, and would upload the other
|
|
2429
|
-
* tenant's bytes into acme's bucket under the key `knowledge/…`.
|
|
2430
|
-
*
|
|
2431
|
-
* The motivating topology (`companies/{co}/knowledge` →
|
|
2432
|
-
* `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
|
|
2433
|
-
* that escapes HQ, or one that reaches another tenant, is refused — and under
|
|
2434
|
-
* the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
|
|
2435
|
-
*
|
|
2436
|
-
* Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
|
|
2437
|
-
* externally-linked subtree can only be recognized while that company's folder
|
|
2438
|
-
* is materialized locally and publishes the link. A machine holding
|
|
2439
|
-
* `repos/private/knowledge-other` with no `companies/other` folder has no
|
|
2440
|
-
* on-disk evidence of the claim, so a link into it is indistinguishable from a
|
|
2441
|
-
* link into any other local repo. Ownership metadata (not path shape) is what
|
|
2442
|
-
* would close that gap.
|
|
2443
|
-
*/
|
|
2444
|
-
function isWithinLexicalOrReal(
|
|
2445
|
-
parent: string,
|
|
2446
|
-
child: string,
|
|
2447
|
-
hqRoot: string,
|
|
2448
|
-
tenantRootsCache?: Map<string, string[]>,
|
|
2449
|
-
): boolean {
|
|
2450
|
-
// Strict arm first: the realpath is genuinely inside the company folder.
|
|
2451
|
-
// This is the overwhelmingly common case, needs no relaxation, and costs no
|
|
2452
|
-
// directory scan.
|
|
2453
|
-
if (isWithin(parent, child)) return true;
|
|
2454
|
-
|
|
2455
|
-
const resolvedChild = path.resolve(child);
|
|
2456
|
-
if (!isPathWithin(path.resolve(parent), resolvedChild)) return false;
|
|
2457
|
-
|
|
2458
|
-
const childReal = realpathSafe(resolvedChild);
|
|
2459
|
-
if (!isWithin(hqRoot, childReal)) return false; // bound 1: escapes HQ
|
|
2460
|
-
|
|
2461
|
-
// NUL-joined: it is the one byte a path cannot contain, so no pair of
|
|
2462
|
-
// (hqRoot, parent) values can collide on the key.
|
|
2463
|
-
const cacheKey = `${hqRoot}\u0000${parent}`;
|
|
2464
|
-
let foreignRoots = tenantRootsCache?.get(cacheKey);
|
|
2465
|
-
if (foreignRoots === undefined) {
|
|
2466
|
-
foreignRoots = foreignTenantRoots(hqRoot, parent);
|
|
2467
|
-
tenantRootsCache?.set(cacheKey, foreignRoots);
|
|
2468
|
-
}
|
|
2469
|
-
for (const foreign of foreignRoots) {
|
|
2470
|
-
if (isPathWithin(foreign, childReal)) return false; // bound 2: other tenant
|
|
2471
|
-
}
|
|
2472
|
-
return true;
|
|
2473
|
-
}
|
|
2474
|
-
|
|
2475
|
-
/**
|
|
2476
|
-
* Depth (in path segments below a company root) at which we look for the
|
|
2477
|
-
* directory symlinks a company publishes into its own folder. HQ's linked
|
|
2478
|
-
* topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
|
|
2479
|
-
* (`companies/{co}/repos/{name}`); going deeper would turn a containment check
|
|
2480
|
-
* into a full-tree walk for no additional coverage.
|
|
2481
|
-
*/
|
|
2482
|
-
const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
|
|
2483
|
-
|
|
2484
|
-
/**
|
|
2485
|
-
* Canonicalized roots that belong to a tenant OTHER than the one being pushed.
|
|
2486
|
-
* A lexically-contained path whose realpath lands inside any of these is
|
|
2487
|
-
* another company's data wearing this company's key, and must be refused.
|
|
2488
|
-
*
|
|
2489
|
-
* Two kinds of root are collected per foreign company:
|
|
2490
|
-
* - the company folder itself (`companies/{other}`), and
|
|
2491
|
-
* - the targets of the directory symlinks that folder publishes — HQ's
|
|
2492
|
-
* pattern-2 topology puts a company's knowledge in `repos/private/
|
|
2493
|
-
* knowledge-{other}` and links it in, so the bytes live OUTSIDE every
|
|
2494
|
-
* `companies/` root and a companies-only check would miss them entirely.
|
|
2495
|
-
*
|
|
2496
|
-
* Only consulted on the rare lexical arm (a path whose realpath is not inside
|
|
2497
|
-
* the company folder), never on the ordinary in-tree push, so the directory
|
|
2498
|
-
* scan is not on the hot path. Callers may memoize it for the duration of a
|
|
2499
|
-
* single collect pass; nothing memoizes it for longer, because the sync runner
|
|
2500
|
-
* is long-lived and a stale tenant map fails OPEN — the wrong direction for a
|
|
2501
|
-
* boundary whose whole job is to refuse.
|
|
2502
|
-
*/
|
|
2503
|
-
function foreignTenantRoots(hqRoot: string, syncRoot: string): string[] {
|
|
2504
|
-
const companiesDir = path.join(hqRoot, "companies");
|
|
2505
|
-
let entries: fs.Dirent[];
|
|
2506
|
-
try {
|
|
2507
|
-
entries = fs.readdirSync(companiesDir, { withFileTypes: true });
|
|
2508
|
-
} catch {
|
|
2509
|
-
return []; // no companies/ tree here — nothing to be foreign to
|
|
2510
|
-
}
|
|
2511
|
-
|
|
2512
|
-
const activeReal = realpathSafe(syncRoot);
|
|
2513
|
-
const roots: string[] = [];
|
|
2514
|
-
for (const entry of entries) {
|
|
2515
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
2516
|
-
const companyRoot = path.join(companiesDir, entry.name);
|
|
2517
|
-
const companyReal = realpathSafe(companyRoot);
|
|
2518
|
-
if (companyReal === activeReal) continue; // this is the tenant being pushed
|
|
2519
|
-
roots.push(companyReal);
|
|
2520
|
-
collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
|
|
2521
|
-
}
|
|
2522
|
-
return roots;
|
|
2523
|
-
}
|
|
2524
|
-
|
|
2525
|
-
/**
|
|
2526
|
-
* Append the resolved targets of the directory symlinks published under
|
|
2527
|
-
* `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
|
|
2528
|
-
* inside the company folder are skipped — they add no reach beyond the root
|
|
2529
|
-
* already recorded. Errors are swallowed per entry on purpose: an unreadable
|
|
2530
|
-
* sibling directory must narrow what we can prove, never abort the push it is
|
|
2531
|
-
* unrelated to.
|
|
2532
|
-
*/
|
|
2533
|
-
function collectPublishedLinkTargets(
|
|
2534
|
-
dir: string,
|
|
2535
|
-
companyReal: string,
|
|
2536
|
-
depth: number,
|
|
2537
|
-
out: string[],
|
|
2538
|
-
): void {
|
|
2539
|
-
if (depth <= 0) return;
|
|
2540
|
-
let entries: fs.Dirent[];
|
|
2541
|
-
try {
|
|
2542
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2543
|
-
} catch {
|
|
2544
|
-
return;
|
|
2545
|
-
}
|
|
2546
|
-
for (const entry of entries) {
|
|
2547
|
-
const child = path.join(dir, entry.name);
|
|
2548
|
-
if (entry.isSymbolicLink()) {
|
|
2549
|
-
let real: string;
|
|
2550
|
-
try {
|
|
2551
|
-
real = fs.realpathSync.native(child);
|
|
2552
|
-
} catch {
|
|
2553
|
-
continue; // dangling link claims nothing
|
|
2554
|
-
}
|
|
2555
|
-
try {
|
|
2556
|
-
if (!fs.statSync(real).isDirectory()) continue;
|
|
2557
|
-
} catch {
|
|
2558
|
-
continue;
|
|
2559
|
-
}
|
|
2560
|
-
if (isPathWithin(companyReal, real)) continue; // no reach beyond the root
|
|
2561
|
-
out.push(real);
|
|
2562
|
-
} else if (entry.isDirectory()) {
|
|
2563
|
-
collectPublishedLinkTargets(child, companyReal, depth - 1, out);
|
|
2564
|
-
}
|
|
2565
|
-
}
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
/**
|
|
2569
|
-
* If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
|
|
2570
|
-
* `onLinkedSubtree` so the caller can report that the link's contents were not
|
|
2571
|
-
* uploaded to the vault. Fires only for links that (a) resolve to a directory
|
|
2572
|
-
* and (b) point outside the company folder — an in-tree link is descended
|
|
2573
|
-
* elsewhere, and a dangling or file link has nothing behind it to report.
|
|
2574
|
-
*/
|
|
2575
|
-
function reportLinkedSubtreeIfExternal(
|
|
2576
|
-
linkPath: string,
|
|
2577
|
-
syncRoot: string,
|
|
2578
|
-
relativePath: string,
|
|
2579
|
-
hooks: CollectHooks,
|
|
2580
|
-
): void {
|
|
2581
|
-
if (!hooks.onLinkedSubtree) return;
|
|
2582
|
-
let real: string;
|
|
2583
|
-
try {
|
|
2584
|
-
real = fs.realpathSync.native(linkPath); // resolves the link to its target
|
|
2585
|
-
} catch {
|
|
2586
|
-
return; // dangling link — nothing behind it to report
|
|
2587
|
-
}
|
|
2588
|
-
let targetStat: fs.Stats;
|
|
2589
|
-
try {
|
|
2590
|
-
targetStat = fs.statSync(real);
|
|
2591
|
-
} catch {
|
|
2592
|
-
return;
|
|
2593
|
-
}
|
|
2594
|
-
if (!targetStat.isDirectory()) return;
|
|
2595
|
-
if (isWithin(syncRoot, real)) return; // in-tree target: descended elsewhere
|
|
2596
|
-
hooks.onLinkedSubtree(relativePath);
|
|
2597
|
-
}
|
|
2598
|
-
|
|
2599
|
-
/**
|
|
2600
|
-
* Collect files from paths (expanding directories recursively).
|
|
2601
|
-
*
|
|
2602
|
-
* Remote S3 keys are computed relative to `syncRoot` (companies/{slug}/), not
|
|
2603
|
-
* `hqRoot`. Files outside `syncRoot` are skipped with a warning — sharing
|
|
2604
|
-
* anything outside a company's folder would leak state into the wrong vault.
|
|
2605
|
-
*
|
|
2606
|
-
* Symlink classification uses lstat (not stat), so a top-level path that is
|
|
2607
|
-
* itself a symlink is recorded as a link record rather than dereferenced.
|
|
2608
|
-
* Pre-fix, statSync followed the link and the target's bytes were uploaded
|
|
2609
|
-
* under the link's key — silently flattening the link topology.
|
|
2610
|
-
*/
|
|
2611
|
-
function collectFiles(
|
|
2612
|
-
paths: string[],
|
|
2613
|
-
hqRoot: string,
|
|
2614
|
-
syncRoot: string,
|
|
2615
|
-
filter: (p: string, isDir?: boolean) => boolean,
|
|
2616
|
-
hooks: CollectHooks = {},
|
|
2617
|
-
): CollectedEntry[] {
|
|
2618
|
-
const results: CollectedEntry[] = [];
|
|
2619
|
-
// Scoped to THIS collect pass and discarded with it: a watcher batch can
|
|
2620
|
-
// name hundreds of paths, and rescanning the companies/ tree for each one
|
|
2621
|
-
// is wasted I/O. A cache that outlived the pass could fail open on a tenant
|
|
2622
|
-
// boundary, so it deliberately does not.
|
|
2623
|
-
const tenantRoots = new Map<string, string[]>();
|
|
2624
|
-
|
|
2625
|
-
for (const p of paths) {
|
|
2626
|
-
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
2627
|
-
|
|
2628
|
-
// Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
|
|
2629
|
-
// Caller may pass one explicitly; we still refuse to upload it. Basename
|
|
2630
|
-
// check matches the walkDir gate so behavior is identical whether the
|
|
2631
|
-
// mirror is the user-supplied path or found during directory recursion.
|
|
2632
|
-
if (isEphemeralPath(path.basename(absolutePath))) continue;
|
|
2633
|
-
|
|
2634
|
-
// existsSync follows symlinks: a dangling top-level link will report
|
|
2635
|
-
// not-existing and be skipped here. lstatSync below handles the
|
|
2636
|
-
// valid-link case directly without needing the existsSync gate.
|
|
2637
|
-
let lstat: fs.Stats;
|
|
2638
|
-
try {
|
|
2639
|
-
lstat = fs.lstatSync(absolutePath);
|
|
2640
|
-
} catch {
|
|
2641
|
-
console.error(` Warning: ${p} does not exist, skipping.`);
|
|
2642
|
-
hooks.onUnreachablePath?.(p, "missing");
|
|
2643
|
-
continue;
|
|
2644
|
-
}
|
|
2645
|
-
|
|
2646
|
-
// Containment check is split by entry kind: regular files and
|
|
2647
|
-
// directories use isWithin (which canonicalizes via realpathSync to
|
|
2648
|
-
// tolerate macOS APFS case-insensitivity), but symlinks use the
|
|
2649
|
-
// link's own pathname rather than the link's resolved target. A
|
|
2650
|
-
// valid use case for this asymmetry: a directory symlink whose
|
|
2651
|
-
// target lives outside the company folder (e.g. companies/{co}/
|
|
2652
|
-
// knowledge → repos/private/knowledge-{co}/) — the LINK itself is
|
|
2653
|
-
// inside the company folder and is exactly what we want to record,
|
|
2654
|
-
// but isWithin's realpath would say the resolved target is outside
|
|
2655
|
-
// and reject the share. Recording symlinks rather than following
|
|
2656
|
-
// them is the whole topology contract this fix establishes; the
|
|
2657
|
-
// containment check needs to honor the same semantic.
|
|
2658
|
-
if (lstat.isSymbolicLink()) {
|
|
2659
|
-
if (!isWithinForLink(syncRoot, absolutePath)) {
|
|
2660
|
-
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
2661
|
-
hooks.onUnreachablePath?.(p, "outside-company");
|
|
2662
|
-
continue;
|
|
2663
|
-
}
|
|
2664
|
-
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2665
|
-
// Probe the filter with both isDir hints — we don't know whether
|
|
2666
|
-
// the link's target is a file or a directory without
|
|
2667
|
-
// stat-following the link, which we explicitly avoid (it would
|
|
2668
|
-
// re-introduce the dereference behavior this whole change set is
|
|
2669
|
-
// designed to prevent). An `.hqinclude` dir-only pattern like
|
|
2670
|
-
// `companies/*/knowledge/` only matches with isDir=true, so a
|
|
2671
|
-
// single isDir=false probe would silently drop directory
|
|
2672
|
-
// symlinks under allowlist mode (the motivating case for this
|
|
2673
|
-
// whole branch). The filter is pure path lookup with no I/O,
|
|
2674
|
-
// so two calls are free.
|
|
2675
|
-
if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
|
|
2676
|
-
const target = readlinkOrNull(absolutePath);
|
|
2677
|
-
if (target === null) {
|
|
2678
|
-
console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
|
|
2679
|
-
hooks.onUnreachablePath?.(p, "unreadable-link");
|
|
2680
|
-
continue;
|
|
2681
|
-
}
|
|
2682
|
-
// A directory symlink whose target lives outside the company folder is
|
|
2683
|
-
// recorded here but never descended — its contents ship via their own
|
|
2684
|
-
// repo, not the vault. Surface it so files created under such a link
|
|
2685
|
-
// don't vanish from every push bucket silently.
|
|
2686
|
-
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
|
|
2687
|
-
results.push({
|
|
2688
|
-
kind: "symlink",
|
|
2689
|
-
absolutePath,
|
|
2690
|
-
relativePath,
|
|
2691
|
-
target,
|
|
2692
|
-
});
|
|
2693
|
-
continue;
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
|
-
if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
|
|
2697
|
-
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
2698
|
-
hooks.onUnreachablePath?.(p, "outside-company");
|
|
2699
|
-
continue;
|
|
2700
|
-
}
|
|
2701
|
-
|
|
2702
|
-
if (lstat.isDirectory()) {
|
|
2703
|
-
if (!filter(absolutePath, true)) continue;
|
|
2704
|
-
walkDir(absolutePath, syncRoot, filter, results, hooks);
|
|
2705
|
-
} else if (lstat.isFile()) {
|
|
2706
|
-
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2707
|
-
if (filter(absolutePath)) {
|
|
2708
|
-
results.push({ kind: "file", absolutePath, relativePath });
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
|
|
2713
|
-
return results;
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
|
-
function walkDir(
|
|
2717
|
-
dir: string,
|
|
2718
|
-
syncRoot: string,
|
|
2719
|
-
filter: (p: string, isDir?: boolean) => boolean,
|
|
2720
|
-
results: CollectedEntry[],
|
|
2721
|
-
hooks: CollectHooks = {},
|
|
2722
|
-
): void {
|
|
2723
|
-
// A frame per open directory preserves the recursive walk's depth-first
|
|
2724
|
-
// ordering without turning a completed subtree into one giant call argument
|
|
2725
|
-
// list. This matters for both operator-visible plan ordering and large trees.
|
|
2726
|
-
const stack: Array<{ dir: string; entries: fs.Dirent[]; index: number }> = [];
|
|
2727
|
-
const enterDirectory = (currentDir: string): void => {
|
|
2728
|
-
if (!fs.existsSync(currentDir)) return;
|
|
2729
|
-
stack.push({
|
|
2730
|
-
dir: currentDir,
|
|
2731
|
-
entries: fs.readdirSync(currentDir, { withFileTypes: true }),
|
|
2732
|
-
index: 0,
|
|
2733
|
-
});
|
|
2734
|
-
};
|
|
2735
|
-
|
|
2736
|
-
enterDirectory(dir);
|
|
2737
|
-
while (stack.length > 0) {
|
|
2738
|
-
const frame = stack[stack.length - 1];
|
|
2739
|
-
if (frame.index >= frame.entries.length) {
|
|
2740
|
-
stack.pop();
|
|
2741
|
-
continue;
|
|
2742
|
-
}
|
|
2743
|
-
const entry = frame.entries[frame.index++];
|
|
2744
|
-
// Ephemeral artifacts (conflict mirrors) are local-only safety backups
|
|
2745
|
-
// that MUST NEVER round-trip to S3. Check basename here so the filter
|
|
2746
|
-
// applies regardless of which company root contains them. See
|
|
2747
|
-
// EPHEMERAL_PATH_PATTERN doc for the full rationale.
|
|
2748
|
-
if (isEphemeralPath(entry.name)) continue;
|
|
2749
|
-
const absolutePath = path.join(frame.dir, entry.name);
|
|
2750
|
-
const isDir = entry.isDirectory();
|
|
2751
|
-
|
|
2752
|
-
// Symlinks need their own filter probe BEFORE the regular gate.
|
|
2753
|
-
// Dirent.isDirectory() returns false for any symlink — even a
|
|
2754
|
-
// directory symlink — so the regular filter call below would use
|
|
2755
|
-
// isDir=false and a dir-only allowlist pattern like
|
|
2756
|
-
// `companies/*/knowledge/` would reject the link before the
|
|
2757
|
-
// record-only branch runs. Probe with both hints; include if
|
|
2758
|
-
// either matches. The filter is pure path lookup with no I/O.
|
|
2759
|
-
if (entry.isSymbolicLink()) {
|
|
2760
|
-
if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
|
|
2761
|
-
// Record the link without descending into its target. Following
|
|
2762
|
-
// a directory symlink would re-enter content via a path that
|
|
2763
|
-
// isn't its on-disk home (e.g. companies/{co}/knowledge → repos/
|
|
2764
|
-
// private/knowledge-{co}/), causing per-company knowledge repos
|
|
2765
|
-
// to be uploaded into every vault that links them. Recording
|
|
2766
|
-
// and not following preserves the link topology while avoiding
|
|
2767
|
-
const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2768
|
-
// On win32, a Dirent-known link is not sufficient proof that readlink
|
|
2769
|
-
// will succeed (notably for some reparse points). Never fall through to
|
|
2770
|
-
// normal file/directory handling here: doing so would dereference the
|
|
2771
|
-
// link and duplicate its target under this vault key.
|
|
2772
|
-
const target = readlinkOrNull(absolutePath);
|
|
2773
|
-
if (target === null) {
|
|
2774
|
-
console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
|
|
2775
|
-
hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
|
|
2776
|
-
continue;
|
|
2777
|
-
}
|
|
2778
|
-
// The link is recorded but its (external) target is not descended, so
|
|
2779
|
-
// any files under it are NOT uploaded. Report the subtree so a full
|
|
2780
|
-
// `sync now` no longer drops it from every bucket without a trace.
|
|
2781
|
-
reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
|
|
2782
|
-
results.push({
|
|
2783
|
-
kind: "symlink",
|
|
2784
|
-
absolutePath,
|
|
2785
|
-
relativePath: linkRelative,
|
|
2786
|
-
target,
|
|
2787
|
-
});
|
|
2788
|
-
continue;
|
|
2789
|
-
}
|
|
2790
|
-
|
|
2791
|
-
// Pass the dir hint so dir-only ignore/include patterns (`foo/`)
|
|
2792
|
-
// resolve correctly for the descent decision.
|
|
2793
|
-
if (!filter(absolutePath, isDir)) continue;
|
|
2794
|
-
|
|
2795
|
-
if (isDir) {
|
|
2796
|
-
enterDirectory(absolutePath);
|
|
2797
|
-
} else if (entry.isFile()) {
|
|
2798
|
-
results.push({
|
|
2799
|
-
kind: "file",
|
|
2800
|
-
absolutePath,
|
|
2801
|
-
relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
|
|
2802
|
-
});
|
|
2803
|
-
}
|
|
2804
|
-
}
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
function isWithin(parent: string, child: string): boolean {
|
|
2808
|
-
// Canonicalize both ends so the comparison survives case-insensitive
|
|
2809
|
-
// filesystems (macOS APFS, Windows NTFS): `path.relative('/Users/x/hq',
|
|
2810
|
-
// '/Users/x/HQ/foo')` returns `'../HQ/foo'`, which would falsely report
|
|
2811
|
-
// `child` as outside `parent`. `realpathSync.native` resolves to the
|
|
2812
|
-
// on-disk canonical case so the relative path lands inside.
|
|
2813
|
-
const parentCanon = realpathSafe(parent);
|
|
2814
|
-
const childCanon = realpathSafe(child);
|
|
2815
|
-
const rel = path.relative(parentCanon, childCanon);
|
|
2816
|
-
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
2817
|
-
}
|
|
2818
|
-
|
|
2819
|
-
function isPathWithin(parent: string, child: string): boolean {
|
|
2820
|
-
const rel = path.relative(parent, child);
|
|
2821
|
-
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
2822
|
-
}
|
|
2823
|
-
|
|
2824
|
-
function realpathSafe(p: string): string {
|
|
2825
|
-
try {
|
|
2826
|
-
return fs.realpathSync.native(p);
|
|
2827
|
-
} catch {
|
|
2828
|
-
return p;
|
|
2829
|
-
}
|
|
2830
|
-
}
|
|
2831
|
-
|
|
2832
|
-
function deepestExistingAncestor(start: string): string | null {
|
|
2833
|
-
let current = start;
|
|
2834
|
-
for (;;) {
|
|
2835
|
-
try {
|
|
2836
|
-
fs.lstatSync(current);
|
|
2837
|
-
return current;
|
|
2838
|
-
} catch (err: unknown) {
|
|
2839
|
-
const code =
|
|
2840
|
-
err && typeof err === "object" && "code" in err
|
|
2841
|
-
? (err as { code?: string }).code
|
|
2842
|
-
: undefined;
|
|
2843
|
-
if (code !== "ENOENT" && code !== "ENOTDIR") return null;
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
const parent = path.dirname(current);
|
|
2847
|
-
if (parent === current) return null;
|
|
2848
|
-
current = parent;
|
|
2849
|
-
}
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
function isMaterializationPathStillContained(root: string, localPath: string): boolean {
|
|
2853
|
-
const resolvedRoot = path.resolve(root);
|
|
2854
|
-
const resolvedLocal = path.resolve(localPath);
|
|
2855
|
-
if (!isPathWithin(resolvedRoot, resolvedLocal)) return false;
|
|
2856
|
-
|
|
2857
|
-
let realRoot: string;
|
|
2858
|
-
try {
|
|
2859
|
-
realRoot = fs.realpathSync.native(resolvedRoot);
|
|
2860
|
-
} catch {
|
|
2861
|
-
return false;
|
|
2862
|
-
}
|
|
2863
|
-
|
|
2864
|
-
const existingAncestor = deepestExistingAncestor(path.dirname(resolvedLocal));
|
|
2865
|
-
if (existingAncestor === null) return false;
|
|
2866
|
-
try {
|
|
2867
|
-
const realAncestor = fs.realpathSync.native(existingAncestor);
|
|
2868
|
-
return isPathWithin(realRoot, realAncestor);
|
|
2869
|
-
} catch {
|
|
2870
|
-
return false;
|
|
2871
|
-
}
|
|
2872
|
-
}
|
|
2873
|
-
|
|
2874
|
-
/**
|
|
2875
|
-
* Containment check tailored for symlinks. Canonicalizes the link's
|
|
2876
|
-
* PARENT DIR (which is a real dir, not the link), then compares the
|
|
2877
|
-
* recombined `parentReal/basename(linkPath)` against `parent`. Skipping
|
|
2878
|
-
* the link's own canonicalization means a symlink that points outside
|
|
2879
|
-
* `parent` is still considered "inside" so long as the link file itself
|
|
2880
|
-
* lives inside — which is exactly the topology we want to upload as a
|
|
2881
|
-
* link record without dereferencing.
|
|
2882
|
-
*
|
|
2883
|
-
* Falls back to `parent` / `path.dirname(linkPath)` literally when
|
|
2884
|
-
* realpath throws (e.g. permission denied on a parent), trading a tiny
|
|
2885
|
-
* window of macOS-APFS case-sensitivity drift for the more common case
|
|
2886
|
-
* of "link lives inside, target lives outside."
|
|
2887
|
-
*/
|
|
2888
|
-
function isWithinForLink(parent: string, linkPath: string): boolean {
|
|
2889
|
-
const parentReal = realpathSafe(parent);
|
|
2890
|
-
const linkParentReal = realpathSafe(path.dirname(linkPath));
|
|
2891
|
-
const candidate = path.join(linkParentReal, path.basename(linkPath));
|
|
2892
|
-
const rel = path.relative(parentReal, candidate);
|
|
2893
|
-
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
2894
|
-
}
|
|
2895
|
-
|
|
2896
|
-
/**
|
|
2897
|
-
* Resolve each user-supplied share path to a directory under `syncRoot`,
|
|
2898
|
-
* returning the company-relative prefix that constrains delete propagation.
|
|
2899
|
-
* Files (non-directories) and paths outside the company root are dropped —
|
|
2900
|
-
* a delete sweep keyed off a single file or a sibling tree would surprise
|
|
2901
|
-
* users who expected deletes to mirror the targeted scope.
|
|
2902
|
-
*
|
|
2903
|
-
* Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
|
|
2904
|
-
* itself; this is the bidirectional-runner case.
|
|
2905
|
-
*
|
|
2906
|
-
* Path spellings are resolved with `resolveNamedPath`, the same resolver the
|
|
2907
|
-
* upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
|
|
2908
|
-
* like its absolute equivalent instead of silently resolving nowhere and
|
|
2909
|
-
* scoping nothing.
|
|
2910
|
-
*
|
|
2911
|
-
* Containment, however, deliberately stays on strict realpath `isWithin` and
|
|
2912
|
-
* does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
|
|
2913
|
-
* legs are asymmetric on purpose: the upload leg ships the files it was handed,
|
|
2914
|
-
* while a delete scope is a PREFIX that authorizes removing every remote object
|
|
2915
|
-
* beneath it. A linked subtree's contents are never walked (`walkDir` does not
|
|
2916
|
-
* descend external directory symlinks), so anchoring a delete scope on one
|
|
2917
|
-
* would compare an empty local walk against a populated remote prefix and sweep
|
|
2918
|
-
* the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
|
|
2919
|
-
* accepted cost is that deletes inside a linked subtree are not propagated,
|
|
2920
|
-
* which matches the snapshot semantics `hq sync push` already documents for
|
|
2921
|
-
* those paths.
|
|
2922
|
-
*/
|
|
2923
|
-
function resolveDeleteScopeRoots(
|
|
2924
|
-
paths: string[],
|
|
2925
|
-
hqRoot: string,
|
|
2926
|
-
syncRoot: string,
|
|
2927
|
-
explicitRoots: readonly string[] = [],
|
|
2928
|
-
): string[] {
|
|
2929
|
-
const prefixes = new Set<string>();
|
|
2930
|
-
for (const root of explicitRoots) {
|
|
2931
|
-
const normalized = root
|
|
2932
|
-
.split("\\")
|
|
2933
|
-
.join("/")
|
|
2934
|
-
.replace(/^\.\/+/, "")
|
|
2935
|
-
.replace(/\/+$/, "");
|
|
2936
|
-
if (
|
|
2937
|
-
normalized === "" ||
|
|
2938
|
-
normalized === "." ||
|
|
2939
|
-
path.posix.isAbsolute(normalized) ||
|
|
2940
|
-
normalized === ".." ||
|
|
2941
|
-
normalized.startsWith("../")
|
|
2942
|
-
) {
|
|
2943
|
-
continue;
|
|
2944
|
-
}
|
|
2945
|
-
prefixes.add(normalized);
|
|
2946
|
-
}
|
|
2947
|
-
for (const p of paths) {
|
|
2948
|
-
const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
|
|
2949
|
-
if (!fs.existsSync(absolutePath)) continue;
|
|
2950
|
-
if (!isWithin(syncRoot, absolutePath)) continue;
|
|
2951
|
-
const stat = fs.statSync(absolutePath);
|
|
2952
|
-
if (!stat.isDirectory()) continue;
|
|
2953
|
-
const rel = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2954
|
-
if (rel === "" || rel === ".") {
|
|
2955
|
-
return [""];
|
|
2956
|
-
}
|
|
2957
|
-
prefixes.add(rel);
|
|
2958
|
-
}
|
|
2959
|
-
return Array.from(prefixes);
|
|
2960
|
-
}
|
|
2961
|
-
|
|
2962
|
-
/**
|
|
2963
|
-
* Reason a candidate was bucketed into `refusedStale`. Discriminated so
|
|
2964
|
-
* consumers (UI, telemetry, the event logger) can branch on intent without
|
|
2965
|
-
* string-comparing the placeholder etag value.
|
|
2966
|
-
* - `"stale-etag"` → currency-gated saw a real etag mismatch (peer
|
|
2967
|
-
* drift). `journalEtag` and `remoteEtag` are both
|
|
2968
|
-
* real ETag values.
|
|
2969
|
-
* - `"legacy-no-etag"` → journal entry was written before remoteEtag was
|
|
2970
|
-
* tracked. `journalEtag` and `remoteEtag` are
|
|
2971
|
-
* placeholder sentinels — do not display as ETags.
|
|
2972
|
-
*/
|
|
2973
|
-
type RefusedStaleReason =
|
|
2974
|
-
| "stale-etag"
|
|
2975
|
-
| "legacy-no-etag"
|
|
2976
|
-
| "bulk-asymmetry"
|
|
2977
|
-
| "divergent-local"
|
|
2978
|
-
| "missing-delete-intent"
|
|
2979
|
-
| "intent-changed"
|
|
2980
|
-
| "recreated-locally";
|
|
2981
|
-
|
|
2982
|
-
/**
|
|
2983
|
-
* Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
|
|
2984
|
-
* fraction of the in-scope journal entries into remote `DeleteObject` calls.
|
|
2985
|
-
*
|
|
2986
|
-
* Failure mode this defends against: the local mirror is corrupt
|
|
2987
|
-
* (moved hqRoot, partial restore from backup, fresh clone over an inherited
|
|
2988
|
-
* `~/.hq/sync-journal.*.json`, unmounted external volume, accidental
|
|
2989
|
-
* `rm -rf` of a populated subtree). `computeDeletePlan` walks every journal
|
|
2990
|
-
* entry, lstats locally, and any ENOENT is a delete candidate; under
|
|
2991
|
-
* `currency-gated` the per-file HEAD passes in the steady state (nobody
|
|
2992
|
-
* else rewrote the object) so the delete proceeds. Per-file currency is
|
|
2993
|
-
* orthogonal to whole-set health — when a large slice of the mirror is
|
|
2994
|
-
* absent at once, the engine should refuse rather than amplify the
|
|
2995
|
-
* accident into a mass-DELETE.
|
|
2996
|
-
*
|
|
2997
|
-
* The guard fires when **both** conditions hold:
|
|
2998
|
-
* - `candidates / inScope >= BULK_ASYMMETRY_RATIO` (default 10%).
|
|
2999
|
-
* - `candidates >= BULK_ASYMMETRY_MIN_ABS` (default 10).
|
|
3000
|
-
*
|
|
3001
|
-
* `candidates` is the whole prospective destructive set, whether or not each
|
|
3002
|
-
* entry carries a current watcher delete-intent. A live watcher stamps one
|
|
3003
|
-
* intent per descendant when a directory disappears, so intent presence says
|
|
3004
|
-
* nothing about whether the disappearance was deliberate — see the numerator
|
|
3005
|
-
* comment in `computeDeletePlan` and the 2026-07-30 incident.
|
|
3006
|
-
*
|
|
3007
|
-
* The MIN_ABS floor is what lets small intentional deletes through —
|
|
3008
|
-
* `rm 1 file` from a 5-entry mirror is 20% but 1 absolute candidate; never
|
|
3009
|
-
* tripped. The RATIO is what lets large intentional uploads-then-deletes
|
|
3010
|
-
* through — a 10000-entry journal where 50 files were intentionally
|
|
3011
|
-
* removed is 0.5%; never tripped.
|
|
3012
|
-
*
|
|
3013
|
-
* Bypass paths (both intentional opt-outs of safety):
|
|
3014
|
-
* - Env `HQ_SYNC_DELETE_BULK_OVERRIDE=1|true|yes` (case-insensitive). The
|
|
3015
|
-
* rollback knob for the rare legitimate mass-delete; symmetric to
|
|
3016
|
-
* `HQ_SYNC_DELETE_POLICY` as a per-invocation policy override.
|
|
3017
|
-
* - `propagateDeletePolicy: "all"`. The emergency-reconcile policy
|
|
3018
|
-
* already opts out of safety gates (currency check, owned-only).
|
|
3019
|
-
* This guard honors that explicit opt-out.
|
|
3020
|
-
*
|
|
3021
|
-
* `"owned-only"` and `"currency-gated"` both go through the guard. The
|
|
3022
|
-
* `direction === "up"` filter in `owned-only` is no defense once a user
|
|
3023
|
-
* has ever run full bidirectional sync (every uploaded file gets
|
|
3024
|
-
* direction:'up') — see investigation report
|
|
3025
|
-
* `workspace/reports/indigo-vault-mass-delete-debug.md`.
|
|
3026
|
-
*/
|
|
3027
|
-
const BULK_ASYMMETRY_RATIO = 0.10;
|
|
3028
|
-
const BULK_ASYMMETRY_MIN_ABS = 10;
|
|
3029
|
-
const BULK_ASYMMETRY_SAMPLE_CAP = 10;
|
|
3030
|
-
|
|
3031
|
-
function isBulkAsymmetryOverride(): boolean {
|
|
3032
|
-
const v = (process.env.HQ_SYNC_DELETE_BULK_OVERRIDE ?? "").toLowerCase();
|
|
3033
|
-
return v === "1" || v === "true" || v === "yes";
|
|
3034
|
-
}
|
|
3035
|
-
|
|
3036
|
-
/**
|
|
3037
|
-
* Three buckets returned by computeDeletePlan, exposed so the execution
|
|
3038
|
-
* loop can take a different action for each:
|
|
3039
|
-
* - `toDelete` → issue DeleteObject + drop journal entry.
|
|
3040
|
-
* - `toTombstone` → no DeleteObject (remote already 404), drop journal
|
|
3041
|
-
* entry. Lets the journal converge with reality even
|
|
3042
|
-
* when the remote was cleaned out-of-band.
|
|
3043
|
-
* - `refusedStale` → no DeleteObject, no journal change. Some other
|
|
3044
|
-
* device modified the remote object since this device
|
|
3045
|
-
* last synced it; the next pull leg re-pulls via the
|
|
3046
|
-
* same `hasRemoteChanged` path the conflict detector
|
|
3047
|
-
* uses. Emitted as `delete-refused-stale-etag` events.
|
|
3048
|
-
*/
|
|
3049
|
-
interface DeletePlan {
|
|
3050
|
-
toDelete: Array<{ key: string; intentVersion: 1 | null }>;
|
|
3051
|
-
toTombstone: string[];
|
|
3052
|
-
refusedStale: Array<{
|
|
3053
|
-
key: string;
|
|
3054
|
-
journalEtag: string;
|
|
3055
|
-
remoteEtag: string;
|
|
3056
|
-
reason: RefusedStaleReason;
|
|
3057
|
-
}>;
|
|
3058
|
-
/**
|
|
3059
|
-
* Populated only when the bulk-asymmetry circuit-breaker tripped. The
|
|
3060
|
-
* caller emits `delete-refused-bulk-asymmetry` as a one-shot summary so
|
|
3061
|
-
* the UI gets one banner-grade signal in addition to the per-key
|
|
3062
|
-
* `delete-refused-stale-etag` events under `refusedStale`.
|
|
3063
|
-
*/
|
|
3064
|
-
bulkAsymmetry?: {
|
|
3065
|
-
candidates: number;
|
|
3066
|
-
inScope: number;
|
|
3067
|
-
ratio: number;
|
|
3068
|
-
samplePaths: string[];
|
|
3069
|
-
};
|
|
3070
|
-
}
|
|
3071
|
-
|
|
3072
|
-
function hasCurrentLocalDeleteIntent(
|
|
3073
|
-
entry: SyncJournal["files"][string] | undefined,
|
|
3074
|
-
): boolean {
|
|
3075
|
-
const intent = entry?.localDeleteIntent;
|
|
3076
|
-
return !!(
|
|
3077
|
-
intent &&
|
|
3078
|
-
intent.version === 1 &&
|
|
3079
|
-
entry.remoteEtag &&
|
|
3080
|
-
entry.kind &&
|
|
3081
|
-
intent.remoteEtag === entry.remoteEtag &&
|
|
3082
|
-
intent.localHash === entry.hash &&
|
|
3083
|
-
intent.localKind === entry.kind
|
|
3084
|
-
);
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
function isLocallyAbsent(localPath: string): boolean {
|
|
3088
|
-
try {
|
|
3089
|
-
fs.lstatSync(localPath);
|
|
3090
|
-
return false;
|
|
3091
|
-
} catch (err: unknown) {
|
|
3092
|
-
const code =
|
|
3093
|
-
err && typeof err === "object" && "code" in err
|
|
3094
|
-
? (err as { code?: string }).code
|
|
3095
|
-
: undefined;
|
|
3096
|
-
return code === "ENOENT";
|
|
3097
|
-
}
|
|
3098
|
-
}
|
|
3099
|
-
|
|
3100
|
-
/**
|
|
3101
|
-
* Concurrency cap for the per-file HEAD-O-meter (currency-gated). Sequential
|
|
3102
|
-
* HEADs would add ~N×(50-200ms) to a sync — for the 261-mirror real-world
|
|
3103
|
-
* case that's 15-50s of latency. 16-way concurrency keeps S3 well within
|
|
3104
|
-
* per-prefix burst limits (~3,500 GET/HEAD/sec/prefix is the documented
|
|
3105
|
-
* floor) and bounded under the AWS-SDK default agent's max-sockets so we
|
|
3106
|
-
* don't compete with the in-flight upload pool.
|
|
3107
|
-
*/
|
|
3108
|
-
const DELETE_PLAN_HEAD_CONCURRENCY = 16;
|
|
3109
|
-
|
|
3110
|
-
/**
|
|
3111
|
-
* Walk every journal key in `scopeRoots` whose local file is missing from
|
|
3112
|
-
* disk and bucket each candidate into the right action per `policy`. Hard
|
|
3113
|
-
* filters that drop a candidate entirely (no bucket) — regardless of policy:
|
|
3114
|
-
*
|
|
3115
|
-
* 1. Its key must match (or sit beneath) one of the `scopeRoots` prefixes.
|
|
3116
|
-
* 2. Its local file must be missing from disk (lstat ENOENT). We use
|
|
3117
|
-
* `lstat` (not `existsSync`) so a dangling symlink — a link whose
|
|
3118
|
-
* target has been removed but whose link file is still on disk —
|
|
3119
|
-
* counts as "still present locally" and is NOT delete-propagated.
|
|
3120
|
-
* Pre-fix, existsSync followed the link, returned false, and the
|
|
3121
|
-
* entry was queued for remote DeleteObject in the same sync that
|
|
3122
|
-
* had just uploaded it via `uploadSymlink` — the link round-tripped
|
|
3123
|
-
* as "upload, then delete" in one cycle. ENOENT means truly absent
|
|
3124
|
-
* → eligible; other lstat errors propagate.
|
|
3125
|
-
* 3. The current ignore filter (`shouldSync`) accepts the key — paths
|
|
3126
|
-
* filtered out by `.hqignore` / `.gitignore` / `DEFAULT_IGNORES` are
|
|
3127
|
-
* never delete-propagated. Closes the failure mode where a path lives
|
|
3128
|
-
* in the vault (and journal) but the local walk skips it because of
|
|
3129
|
-
* asymmetric ignore rules.
|
|
3130
|
-
*
|
|
3131
|
-
* Dual-hint probe: by the time we're considering this entry for
|
|
3132
|
-
* remote deletion, the local file is already gone — we have no way to
|
|
3133
|
-
* know whether it was a regular file or a symlink record. A single
|
|
3134
|
-
* `isDir=false` probe would silently keep the remote record alive
|
|
3135
|
-
* whenever the only matching `.hqinclude` allowlist pattern is dir-
|
|
3136
|
-
* only (e.g. `companies/*\/knowledge/`), since gitignore's slash
|
|
3137
|
-
* semantics reject the slashless probe. The same dual-hint pattern in
|
|
3138
|
-
* `walkDir`/`collectFiles` (push) and `computePullPlan` (pull) applies
|
|
3139
|
-
* symmetrically here. Pure path lookup, no I/O.
|
|
3140
|
-
* 4. The key does NOT match `EPHEMERAL_PATH_PATTERN`. Conflict mirrors
|
|
3141
|
-
* are local-only artifacts that should never have been journaled in
|
|
3142
|
-
* the first place; the dedicated reconcile command sweeps already-
|
|
3143
|
-
* journaled mirrors. Excluding them here keeps a regular `sync now`
|
|
3144
|
-
* from accidentally deleting a mirror another device is still
|
|
3145
|
-
* reviewing.
|
|
3146
|
-
*
|
|
3147
|
-
* Then per-policy bucketing:
|
|
3148
|
-
*
|
|
3149
|
-
* - `"currency-gated"` (default, safest): issue a HEAD against the remote.
|
|
3150
|
-
* 200 + `normalizeEtag(remote) === entry.remoteEtag` → `toDelete`.
|
|
3151
|
-
* 200 + mismatch → `refusedStale` (peer drift; let pull re-pull).
|
|
3152
|
-
* 404 → `toTombstone` (remote was cleaned out-of-band).
|
|
3153
|
-
* If the journal entry has no recorded `remoteEtag` (legacy entries
|
|
3154
|
-
* written before etag tracking), the candidate falls back to
|
|
3155
|
-
* `refusedStale` with `reason: "legacy-no-etag"` — we can't prove
|
|
3156
|
-
* currency without an etag, so refusal is the safe direction. The
|
|
3157
|
-
* journal entry survives so a future sync with a recorded etag can
|
|
3158
|
-
* re-evaluate.
|
|
3159
|
-
*
|
|
3160
|
-
* HEAD calls are batched at `DELETE_PLAN_HEAD_CONCURRENCY` so a large
|
|
3161
|
-
* candidate set (e.g. a one-shot reconcile sweep) doesn't serialize
|
|
3162
|
-
* into N×RTT latency. The candidate set is materialized into a list
|
|
3163
|
-
* first (synchronous filters above), then the HEAD pass runs in
|
|
3164
|
-
* bounded-parallel chunks.
|
|
3165
|
-
*
|
|
3166
|
-
* Note: there is a TOCTOU window between this HEAD and the eventual
|
|
3167
|
-
* `deleteRemoteFile` call in the share() execution loop. If a peer
|
|
3168
|
-
* overwrites the object in that window (~50-200ms), the resulting
|
|
3169
|
-
* delete-marker lands on a newer version than we verified. S3
|
|
3170
|
-
* versioning makes the worst case recoverable (prior versions are
|
|
3171
|
-
* retained), and the conditional-delete primitive does not exist on
|
|
3172
|
-
* S3 DeleteObject — only PutObject/CopyObject accept `IfMatch`. The
|
|
3173
|
-
* window is bounded, not zero. Realtime sync (separate work) reduces
|
|
3174
|
-
* it further by keeping the journal continuously fresh.
|
|
3175
|
-
* - `"owned-only"`: include only entries with `direction === "up"`. No
|
|
3176
|
-
* HEAD round-trip. Goes to `toDelete`. Legacy fallback.
|
|
3177
|
-
* - `"all"`: include every candidate. No HEAD, no direction check. Goes
|
|
3178
|
-
* to `toDelete`. Caller has explicitly opted out of safety gates.
|
|
3179
|
-
*
|
|
3180
|
-
* Empty `scopeRoots` ⇒ empty plan (caller didn't opt in).
|
|
3181
|
-
*/
|
|
3182
|
-
async function computeDeletePlan(
|
|
3183
|
-
journal: SyncJournal,
|
|
3184
|
-
syncRoot: string,
|
|
3185
|
-
scopeRoots: string[],
|
|
3186
|
-
shouldSync: (filePath: string, isDir?: boolean) => boolean,
|
|
3187
|
-
policy: "currency-gated" | "owned-only" | "all",
|
|
3188
|
-
ctx: EntityContext,
|
|
3189
|
-
// True for a COMPANY-scoped push (personalMode !== true): the sync root is
|
|
3190
|
-
// the company folder and journal keys are bucket-relative, so a literal
|
|
3191
|
-
// `companies/…` key is scope-invalid (see the poisoned-key branch below).
|
|
3192
|
-
// Personal-vault pushes legitimately journal `companies/{slug}/…` keys.
|
|
3193
|
-
companyScoped: boolean = true,
|
|
3194
|
-
): Promise<DeletePlan> {
|
|
3195
|
-
const plan: DeletePlan = { toDelete: [], toTombstone: [], refusedStale: [] };
|
|
3196
|
-
if (scopeRoots.length === 0) return plan;
|
|
3197
|
-
|
|
3198
|
-
// Stage 1: synchronous pre-filter. Walk every journal entry and either
|
|
3199
|
-
// drop it (hard filter), assign it directly to a bucket (owned-only /
|
|
3200
|
-
// all), or queue it for HEAD (currency-gated). Keeping this synchronous
|
|
3201
|
-
// means the HEAD pass below sees a single, deduplicated candidate list
|
|
3202
|
-
// and the journal-mutation buckets are already settled before any I/O.
|
|
3203
|
-
type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 | null };
|
|
3204
|
-
const headCandidates: HeadCandidate[] = [];
|
|
3205
|
-
// Litter drain bucket — kept separate from `plan.toDelete` so the
|
|
3206
|
-
// bulk-asymmetry breaker (which moves toDelete + headCandidates into
|
|
3207
|
-
// `refusedStale` when it trips) can't sweep these out. See
|
|
3208
|
-
// `isVaultLitterArtifact` for the patterns and rationale: by construction
|
|
3209
|
-
// these aren't user content losses, so a high ratio of litter must not
|
|
3210
|
-
// refuse the drain — that's the whole point of the bypass. Merged into
|
|
3211
|
-
// `plan.toDelete` after the breaker check.
|
|
3212
|
-
const litterToDelete: string[] = [];
|
|
3213
|
-
// Bulk-asymmetry tracking: count every in-scope journal entry (denominator)
|
|
3214
|
-
// and every entry that would have been a delete-candidate before the guard
|
|
3215
|
-
// (numerator). The numerator is the WHOLE prospective destructive set:
|
|
3216
|
-
// intent-backed picks (owned-only/all toDelete, currency-gated
|
|
3217
|
-
// headCandidates, legacy-no-etag refusals) AND intent-less disappearances.
|
|
3218
|
-
//
|
|
3219
|
-
// 6.14.19 (#227) narrowed it to intent-less disappearances only, on the
|
|
3220
|
-
// premise that a current watcher intent is affirmative evidence of a
|
|
3221
|
-
// deliberate delete. A wiped, swapped, unmounted or partially-restored tree
|
|
3222
|
-
// observed by a live watcher mints an intent per descendant, so that premise
|
|
3223
|
-
// does not hold and the breaker stopped covering the exact failure mode it
|
|
3224
|
-
// exists for (incident 2026-07-30: 1,108 intent-backed deletions in one
|
|
3225
|
-
// push). Intent currency is a per-file property; the breaker is a whole-set
|
|
3226
|
-
// health check, and the two must not be conflated.
|
|
3227
|
-
//
|
|
3228
|
-
// We do NOT count "ENOENT but ignore-filtered" or "ENOENT but ephemeral" —
|
|
3229
|
-
// those drop out of the plan entirely on their own and don't reflect
|
|
3230
|
-
// mirror-loss intent — nor litter drains (see above), which are intentional
|
|
3231
|
-
// ratchet-cleanup, not mass-delete intent.
|
|
3232
|
-
let inScopeJournalEntries = 0;
|
|
3233
|
-
let bulkCandidatePicks = 0;
|
|
3234
|
-
// Intent-less candidates already parked in `refusedStale`. On a trip they are
|
|
3235
|
-
// re-labelled `bulk-asymmetry` in place rather than refused twice.
|
|
3236
|
-
const intentlessKeys = new Set<string>();
|
|
3237
|
-
|
|
3238
|
-
for (const [relativeKey, entry] of Object.entries(journal.files)) {
|
|
3239
|
-
const inScope = scopeRoots.some(
|
|
3240
|
-
(root) =>
|
|
3241
|
-
root === "" ||
|
|
3242
|
-
relativeKey === root ||
|
|
3243
|
-
relativeKey.startsWith(`${root}/`),
|
|
3244
|
-
);
|
|
3245
|
-
if (!inScope) continue;
|
|
3246
|
-
|
|
3247
|
-
// A tombstoned entry has ALREADY had its disposition decided — by a scope
|
|
3248
|
-
// shrink, `hq sync narrow --apply`, a manual prune, or a pull-side
|
|
3249
|
-
// intentional-delete record. It must never be re-evaluated as a fresh
|
|
3250
|
-
// delete candidate.
|
|
3251
|
-
//
|
|
3252
|
-
// This matters most for `all` -> `shared`: `tombstoneEntry` deliberately
|
|
3253
|
-
// KEEPS `hash`, `direction` and `remoteEtag` (so a recovery flow can see
|
|
3254
|
-
// what was there), and the shrink removes the file locally. Every one of
|
|
3255
|
-
// those entries therefore presents as locally-absent, intent-less and
|
|
3256
|
-
// etag-current — which, now that authorization is etag-only, is an
|
|
3257
|
-
// instruction to delete another member's files out of the shared company
|
|
3258
|
-
// vault, for the crime of having narrowed one's own sync scope.
|
|
3259
|
-
//
|
|
3260
|
-
// Until now the ONLY thing standing in the way was `shouldSync` happening
|
|
3261
|
-
// to carry a `prefixSet` (see `createPushRunContext`); any run that
|
|
3262
|
-
// resolved no prefix set — a degraded scope lookup, a caller that does not
|
|
3263
|
-
// set one — would have walked straight through. That is far too load-
|
|
3264
|
-
// bearing for an implicit dependency, so the invariant is stated here
|
|
3265
|
-
// directly.
|
|
3266
|
-
//
|
|
3267
|
-
// `local-delete` is the ONE reason that must pass: the pull leg stamps it
|
|
3268
|
-
// as a deliberate producer hand-off ("the user removed this; push leg,
|
|
3269
|
-
// finish the job"), and skipping it would strand exactly the deletes this
|
|
3270
|
-
// change exists to enable. Every other reason — scope_shrink,
|
|
3271
|
-
// narrow_apply, manual, or an absent/unrecognised one — means "stop
|
|
3272
|
-
// tracking, do NOT touch the remote", so the test is written fail-closed:
|
|
3273
|
-
// only the explicit hand-off is let through.
|
|
3274
|
-
//
|
|
3275
|
-
// Skipped BEFORE `inScopeJournalEntries++` so these inflate neither the
|
|
3276
|
-
// numerator nor the denominator of the bulk-asymmetry ratio. Excluding
|
|
3277
|
-
// them from the denominator can only make the breaker MORE likely to trip,
|
|
3278
|
-
// which is the safe direction.
|
|
3279
|
-
if (isTombstone(entry) && entry.removedReason !== "local-delete") continue;
|
|
3280
|
-
|
|
3281
|
-
inScopeJournalEntries++;
|
|
3282
|
-
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
3283
|
-
|
|
3284
|
-
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
3285
|
-
// context, a journal entry at a literal `companies/…` key records a
|
|
3286
|
-
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
3287
|
-
// presign transport is rejected by the server validator
|
|
3288
|
-
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
3289
|
-
// straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
|
|
3290
|
-
// remote call). Personal-vault pushes (personalMode) carry legitimate
|
|
3291
|
-
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
3292
|
-
// Checked before the presentLocally gate: the poison file lives at the
|
|
3293
|
-
// doubled path and must drain even while still on disk.
|
|
3294
|
-
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
3295
|
-
plan.toTombstone.push(relativeKey);
|
|
3296
|
-
continue;
|
|
3297
|
-
}
|
|
3298
|
-
|
|
3299
|
-
let presentLocally = true;
|
|
3300
|
-
try {
|
|
3301
|
-
fs.lstatSync(localPath);
|
|
3302
|
-
} catch (err: unknown) {
|
|
3303
|
-
if (
|
|
3304
|
-
err &&
|
|
3305
|
-
typeof err === "object" &&
|
|
3306
|
-
"code" in err &&
|
|
3307
|
-
(err as { code?: string }).code === "ENOENT"
|
|
3308
|
-
) {
|
|
3309
|
-
presentLocally = false;
|
|
3310
|
-
} else {
|
|
3311
|
-
throw err;
|
|
3312
|
-
}
|
|
3313
|
-
}
|
|
3314
|
-
if (presentLocally) continue;
|
|
3315
|
-
|
|
3316
|
-
if (!companyScoped && isPersonalVaultDeletionExcluded(relativeKey)) {
|
|
3317
|
-
continue;
|
|
3318
|
-
}
|
|
3319
|
-
|
|
3320
|
-
// Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
|
|
3321
|
-
// ALWAYS drain, bypassing `shouldSync` (which would skip them when the
|
|
3322
|
-
// parent path is in personal-vault default exclusions like
|
|
3323
|
-
// `.obsidian/**`), the `isEphemeralPath` skip (which protects FRESH
|
|
3324
|
-
// local conflict mirrors but accidentally also protects existing vault
|
|
3325
|
-
// litter), the policy gate (no meaningful ownership/freshness for
|
|
3326
|
-
// litter), and the bulk-asymmetry breaker (intentional ratchet-drain,
|
|
3327
|
-
// not corrupt-mirror mass-delete intent). See `isVaultLitterArtifact`
|
|
3328
|
-
// for the full rationale and patterns. Queued via the separate
|
|
3329
|
-
// `litterToDelete` bucket so the breaker can't sweep these out.
|
|
3330
|
-
if (isVaultLitterArtifact(relativeKey)) {
|
|
3331
|
-
litterToDelete.push(relativeKey);
|
|
3332
|
-
continue;
|
|
3333
|
-
}
|
|
3334
|
-
|
|
3335
|
-
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
3336
|
-
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|
|
3337
|
-
// normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
|
|
3338
|
-
// post-litter-drain above — any key matching EPHEMERAL_PATH_PATTERN was
|
|
3339
|
-
// already routed to `litterToDelete`. Retained as defense-in-depth +
|
|
3340
|
-
// documentation of the policy-side intent.
|
|
3341
|
-
if (isEphemeralPath(relativeKey)) continue;
|
|
3342
|
-
|
|
3343
|
-
// Journal-honesty guard: an entry flagged `localDiverges` recorded the
|
|
3344
|
-
// remote etag only to silence a re-fired conflict (#137) — the local copy
|
|
3345
|
-
// NEVER matched that remote. Its currency would falsely "match" on HEAD, so
|
|
3346
|
-
// propagating a delete would destroy the divergent remote version. Refuse it
|
|
3347
|
-
// regardless of policy, and do NOT count it toward the bulk-asymmetry ratio
|
|
3348
|
-
// (it isn't a genuine mirror-loss candidate). Cleared by a real download.
|
|
3349
|
-
if (entry.localDiverges) {
|
|
3350
|
-
plan.refusedStale.push({
|
|
3351
|
-
key: relativeKey,
|
|
3352
|
-
journalEtag: entry.remoteEtag ?? "<divergent-local>",
|
|
3353
|
-
remoteEtag: "<not-checked>",
|
|
3354
|
-
reason: "divergent-local",
|
|
3355
|
-
});
|
|
3356
|
-
continue;
|
|
3357
|
-
}
|
|
3358
|
-
|
|
3359
|
-
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
3360
|
-
// A delete intent is minted ONLY by a live watcher, at the instant it
|
|
3361
|
-
// observes the unlink. Three of the four ways HQ syncs never construct a
|
|
3362
|
-
// watcher at all — outpost/agent boxes run a one-shot runner, `hq sync
|
|
3363
|
-
// now` is one-shot, and the desktop app drops `--event-push` (and with it
|
|
3364
|
-
// the whole TreeWatcher) whenever Instant-sync is off. On those surfaces
|
|
3365
|
-
// an intent can never exist, so requiring one made deletes structurally
|
|
3366
|
-
// impossible rather than merely gated. Files deleted while no watcher was
|
|
3367
|
-
// running also stranded permanently: the file is already gone, so no
|
|
3368
|
-
// watcher can ever retroactively witness it.
|
|
3369
|
-
//
|
|
3370
|
-
// `currency-gated` therefore authorizes on ETag currency alone. The
|
|
3371
|
-
// per-file question it answers is unchanged — "is the remote object still
|
|
3372
|
-
// exactly what this journal recorded, i.e. has no peer touched it since"
|
|
3373
|
-
// — and a mismatch still refuses (`stale-etag`) so a peer's newer work is
|
|
3374
|
-
// never destroyed.
|
|
3375
|
-
//
|
|
3376
|
-
// The whole-set question ("does my local mirror look catastrophically
|
|
3377
|
-
// empty") is NOT answered by the ETag, and must not be conflated with it:
|
|
3378
|
-
// in the steady state nobody has overwritten anything, so a moved hqRoot,
|
|
3379
|
-
// unmounted volume, fresh clone or partial restore presents mass-ENOENT
|
|
3380
|
-
// with perfectly matching ETags. That is exactly how the 2026-05-25
|
|
3381
|
-
// indigo vault mass-delete destroyed 559 objects. The bulk-asymmetry
|
|
3382
|
-
// breaker below is the guard for that class and is deliberately left
|
|
3383
|
-
// intact — these candidates are counted into `bulkCandidatePicks` on the
|
|
3384
|
-
// SAME line as intent-backed ones, so relaxing the per-file gate cannot
|
|
3385
|
-
// weaken the whole-set one. See policy `sync-delete-bulk-asymmetry-guard`
|
|
3386
|
-
// (hard) and workspace/reports/indigo-vault-mass-delete-debug.md.
|
|
3387
|
-
//
|
|
3388
|
-
// `owned-only` keeps requiring an intent: it is the documented rollback
|
|
3389
|
-
// knob, and an operator who selects it is asking for the stricter path.
|
|
3390
|
-
if (policy === "owned-only") {
|
|
3391
|
-
// Still counted into the breaker exactly as before: an owned-only run
|
|
3392
|
-
// whose mirror has gone missing must trip the whole-set guard even
|
|
3393
|
-
// though each individual key is refused for want of an intent.
|
|
3394
|
-
if (entry.direction === "up") {
|
|
3395
|
-
bulkCandidatePicks++;
|
|
3396
|
-
intentlessKeys.add(relativeKey);
|
|
3397
|
-
}
|
|
3398
|
-
plan.refusedStale.push({
|
|
3399
|
-
key: relativeKey,
|
|
3400
|
-
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
3401
|
-
remoteEtag: "<not-checked>",
|
|
3402
|
-
reason: "missing-delete-intent",
|
|
3403
|
-
});
|
|
3404
|
-
continue;
|
|
3405
|
-
}
|
|
3406
|
-
if (policy === "currency-gated") {
|
|
3407
|
-
bulkCandidatePicks++;
|
|
3408
|
-
intentlessKeys.add(relativeKey);
|
|
3409
|
-
// No etag on record ⇒ nothing to compare ⇒ no authorization. Refused
|
|
3410
|
-
// for the same reason an intent-backed legacy entry is (below).
|
|
3411
|
-
if (!entry.remoteEtag) {
|
|
3412
|
-
plan.refusedStale.push({
|
|
3413
|
-
key: relativeKey,
|
|
3414
|
-
journalEtag: "<legacy-no-etag>",
|
|
3415
|
-
remoteEtag: "<unknown>",
|
|
3416
|
-
reason: "legacy-no-etag",
|
|
3417
|
-
});
|
|
3418
|
-
continue;
|
|
3419
|
-
}
|
|
3420
|
-
headCandidates.push({
|
|
3421
|
-
key: relativeKey,
|
|
3422
|
-
journalEtag: entry.remoteEtag,
|
|
3423
|
-
intentVersion: null,
|
|
3424
|
-
});
|
|
3425
|
-
continue;
|
|
3426
|
-
}
|
|
3427
|
-
// policy === "all" is deliberately NOT relaxed here. It already skips the
|
|
3428
|
-
// bulk-asymmetry breaker, so letting it skip the intent gate too would
|
|
3429
|
-
// make it a wholly unguarded mass delete — a strictly larger blast radius
|
|
3430
|
-
// than this change is scoped to. It keeps refusing intent-less entries
|
|
3431
|
-
// exactly as before.
|
|
3432
|
-
plan.refusedStale.push({
|
|
3433
|
-
key: relativeKey,
|
|
3434
|
-
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
3435
|
-
remoteEtag: "<not-checked>",
|
|
3436
|
-
reason: "missing-delete-intent",
|
|
3437
|
-
});
|
|
3438
|
-
continue;
|
|
3439
|
-
}
|
|
3440
|
-
|
|
3441
|
-
bulkCandidatePicks++;
|
|
3442
|
-
if (policy === "all") {
|
|
3443
|
-
// policy:"all" is the explicit-opt-out emergency-reconcile mode; the
|
|
3444
|
-
// bulk-asymmetry guard skips this branch (caller asserted intent).
|
|
3445
|
-
plan.toDelete.push({
|
|
3446
|
-
key: relativeKey,
|
|
3447
|
-
intentVersion: entry.localDeleteIntent!.version,
|
|
3448
|
-
});
|
|
3449
|
-
continue;
|
|
3450
|
-
}
|
|
3451
|
-
if (policy === "owned-only") {
|
|
3452
|
-
if (entry.direction !== "up") {
|
|
3453
|
-
// Not a delete candidate under owned-only, but it WAS missing
|
|
3454
|
-
// locally. Don't count it for the bulk guard — direction:'down'
|
|
3455
|
-
// entries that vanish locally are silently ignored by this policy
|
|
3456
|
-
// anyway, so they don't represent intent to mass-delete.
|
|
3457
|
-
bulkCandidatePicks--;
|
|
3458
|
-
continue;
|
|
3459
|
-
}
|
|
3460
|
-
plan.toDelete.push({
|
|
3461
|
-
key: relativeKey,
|
|
3462
|
-
intentVersion: entry.localDeleteIntent!.version,
|
|
3463
|
-
});
|
|
3464
|
-
continue;
|
|
3465
|
-
}
|
|
3466
|
-
// currency-gated: queue for HEAD unless the entry is legacy (no etag).
|
|
3467
|
-
const journalEtag = entry.remoteEtag;
|
|
3468
|
-
if (!journalEtag) {
|
|
3469
|
-
plan.refusedStale.push({
|
|
3470
|
-
key: relativeKey,
|
|
3471
|
-
journalEtag: "<legacy-no-etag>",
|
|
3472
|
-
remoteEtag: "<unknown>",
|
|
3473
|
-
reason: "legacy-no-etag",
|
|
3474
|
-
});
|
|
3475
|
-
continue;
|
|
3476
|
-
}
|
|
3477
|
-
headCandidates.push({
|
|
3478
|
-
key: relativeKey,
|
|
3479
|
-
journalEtag,
|
|
3480
|
-
intentVersion: entry.localDeleteIntent!.version,
|
|
3481
|
-
});
|
|
3482
|
-
}
|
|
3483
|
-
|
|
3484
|
-
// Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
|
|
3485
|
-
// rationale + bypass paths. Skipped under policy:"all" (caller asserted
|
|
3486
|
-
// intent) and under `HQ_SYNC_DELETE_BULK_OVERRIDE` (operator rollback knob).
|
|
3487
|
-
if (
|
|
3488
|
-
policy !== "all" &&
|
|
3489
|
-
!isBulkAsymmetryOverride() &&
|
|
3490
|
-
bulkCandidatePicks >= BULK_ASYMMETRY_MIN_ABS &&
|
|
3491
|
-
inScopeJournalEntries > 0 &&
|
|
3492
|
-
bulkCandidatePicks / inScopeJournalEntries >= BULK_ASYMMETRY_RATIO
|
|
3493
|
-
) {
|
|
3494
|
-
// Move every staged candidate (both already-bucketed toDelete from
|
|
3495
|
-
// owned-only and queued headCandidates from currency-gated) into
|
|
3496
|
-
// refusedStale with reason "bulk-asymmetry", and re-label the intent-less
|
|
3497
|
-
// entries already parked there. Journal is not mutated; no DeleteObject is
|
|
3498
|
-
// issued; the Stage-2 HEAD pass never runs.
|
|
3499
|
-
const samplePaths: string[] = [];
|
|
3500
|
-
const noteSample = (key: string): void => {
|
|
3501
|
-
if (samplePaths.length < BULK_ASYMMETRY_SAMPLE_CAP) samplePaths.push(key);
|
|
3502
|
-
};
|
|
3503
|
-
for (const refused of plan.refusedStale) {
|
|
3504
|
-
if (!intentlessKeys.has(refused.key)) continue;
|
|
3505
|
-
refused.journalEtag = "<bulk-asymmetry>";
|
|
3506
|
-
refused.remoteEtag = "<not-checked>";
|
|
3507
|
-
refused.reason = "bulk-asymmetry";
|
|
3508
|
-
noteSample(refused.key);
|
|
3509
|
-
}
|
|
3510
|
-
const pushRefused = (key: string): void => {
|
|
3511
|
-
plan.refusedStale.push({
|
|
3512
|
-
key,
|
|
3513
|
-
journalEtag: "<bulk-asymmetry>",
|
|
3514
|
-
remoteEtag: "<not-checked>",
|
|
3515
|
-
reason: "bulk-asymmetry",
|
|
3516
|
-
});
|
|
3517
|
-
noteSample(key);
|
|
3518
|
-
};
|
|
3519
|
-
for (const item of plan.toDelete) pushRefused(item.key);
|
|
3520
|
-
for (const c of headCandidates) pushRefused(c.key);
|
|
3521
|
-
// Emptying the delete list is what makes the trip a refusal rather than a
|
|
3522
|
-
// label. #227 dropped this line, so a tripped breaker stopped nothing it
|
|
3523
|
-
// had not already refused for another reason.
|
|
3524
|
-
plan.toDelete = [];
|
|
3525
|
-
plan.bulkAsymmetry = {
|
|
3526
|
-
candidates: bulkCandidatePicks,
|
|
3527
|
-
inScope: inScopeJournalEntries,
|
|
3528
|
-
ratio: bulkCandidatePicks / inScopeJournalEntries,
|
|
3529
|
-
samplePaths,
|
|
3530
|
-
};
|
|
3531
|
-
// Litter still drains even when the breaker trips — see `litterToDelete`
|
|
3532
|
-
// declaration. The breaker protects against corrupt-mirror mass-deletes
|
|
3533
|
-
// of user content; litter cleanup is orthogonal and should never be
|
|
3534
|
-
// refused for the same reason new-litter producer-side exclusions
|
|
3535
|
-
// shouldn't block it.
|
|
3536
|
-
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
3537
|
-
return plan;
|
|
3538
|
-
}
|
|
3539
|
-
|
|
3540
|
-
// Stage 2: bounded-parallel HEAD pass. Promise.all over chunks of size
|
|
3541
|
-
// `DELETE_PLAN_HEAD_CONCURRENCY` so a large candidate set doesn't
|
|
3542
|
-
// serialize into N round-trips, and so we don't burst past the AWS-SDK
|
|
3543
|
-
// default agent's per-host socket cap. Each result is bucketed
|
|
3544
|
-
// independently — one failed HEAD doesn't poison the others (errors
|
|
3545
|
-
// propagate from the chunk's Promise.all and are surfaced by share()'s
|
|
3546
|
-
// outer try/catch, mirroring the existing pre-share error handling).
|
|
3547
|
-
// Warm the GET presigns the HEAD pass reuses so a large candidate set doesn't
|
|
3548
|
-
// mint one presign per HEAD and trip the presign breaker. Mirrors the
|
|
3549
|
-
// new-files + tombstone HEAD-pass pre-primes.
|
|
3550
|
-
await primeObjectTransport(
|
|
3551
|
-
ctx,
|
|
3552
|
-
"get",
|
|
3553
|
-
headCandidates.map((c) => c.key),
|
|
3554
|
-
);
|
|
3555
|
-
for (let i = 0; i < headCandidates.length; i += DELETE_PLAN_HEAD_CONCURRENCY) {
|
|
3556
|
-
const chunk = headCandidates.slice(i, i + DELETE_PLAN_HEAD_CONCURRENCY);
|
|
3557
|
-
const results = await Promise.all(
|
|
3558
|
-
chunk.map(async (c) => {
|
|
3559
|
-
try {
|
|
3560
|
-
return {
|
|
3561
|
-
candidate: c,
|
|
3562
|
-
remote: await headRemoteFile(ctx, c.key),
|
|
3563
|
-
scopeExcluded: false,
|
|
3564
|
-
};
|
|
3565
|
-
} catch (err) {
|
|
3566
|
-
if (isAccessDenied(err)) {
|
|
3567
|
-
return { candidate: c, remote: null, scopeExcluded: true };
|
|
3568
|
-
}
|
|
3569
|
-
throw err;
|
|
3570
|
-
}
|
|
3571
|
-
}),
|
|
3572
|
-
);
|
|
3573
|
-
for (const { candidate, remote, scopeExcluded } of results) {
|
|
3574
|
-
if (scopeExcluded) {
|
|
3575
|
-
continue;
|
|
3576
|
-
}
|
|
3577
|
-
if (remote === null) {
|
|
3578
|
-
plan.toTombstone.push(candidate.key);
|
|
3579
|
-
continue;
|
|
3580
|
-
}
|
|
3581
|
-
const currentEtag = normalizeEtag(remote.etag);
|
|
3582
|
-
if (currentEtag === candidate.journalEtag) {
|
|
3583
|
-
plan.toDelete.push({
|
|
3584
|
-
key: candidate.key,
|
|
3585
|
-
intentVersion: candidate.intentVersion,
|
|
3586
|
-
});
|
|
3587
|
-
} else {
|
|
3588
|
-
plan.refusedStale.push({
|
|
3589
|
-
key: candidate.key,
|
|
3590
|
-
journalEtag: candidate.journalEtag,
|
|
3591
|
-
remoteEtag: currentEtag,
|
|
3592
|
-
reason: "stale-etag",
|
|
3593
|
-
});
|
|
3594
|
-
}
|
|
3595
|
-
}
|
|
3596
|
-
}
|
|
3597
|
-
|
|
3598
|
-
// Litter drains alongside the normal candidates — bypasses every gate but
|
|
3599
|
-
// settles into the same plan.toDelete bucket so the share() executor's
|
|
3600
|
-
// delete loop tombstones each key identically (DeleteObject + remove from
|
|
3601
|
-
// journal). Merged at the end so the bulk-asymmetry breaker above had its
|
|
3602
|
-
// chance to NOT include litter in either the numerator or the sweep.
|
|
3603
|
-
for (const key of litterToDelete) {
|
|
3604
|
-
plan.toDelete.push({ key, intentVersion: null });
|
|
3605
|
-
}
|
|
3606
|
-
|
|
3607
|
-
return plan;
|
|
3608
|
-
}
|
|
3609
|
-
|
|
3610
|
-
/**
|
|
3611
|
-
* Walk every journal key that matches one of the supplied `prefixes` and
|
|
3612
|
-
* return the keys eligible for unconditional remote `DeleteObject`. Unlike
|
|
3613
|
-
* `computeDeletePlan` this DOES NOT check whether the local file is missing
|
|
3614
|
-
* — the caller has asserted that these keys no longer belong in this
|
|
3615
|
-
* bucket regardless of local state (typically a company that was promoted
|
|
3616
|
-
* from personal-bucket fallback to its own team bucket).
|
|
3617
|
-
*
|
|
3618
|
-
* An entry is in the plan only when ALL of the following hold:
|
|
3619
|
-
*
|
|
3620
|
-
* 1. Its key matches (or sits beneath) one of the `prefixes`. The match
|
|
3621
|
-
* is exact OR `key.startsWith(prefix + "/")` — same semantics as
|
|
3622
|
-
* `computeDeletePlan`'s scope-root check.
|
|
3623
|
-
* 2. When `policy === "owned-only"`: the journal entry's `direction`
|
|
3624
|
-
* is `"up"` (this machine previously uploaded the file). Mirrors
|
|
3625
|
-
* `computeDeletePlan`'s safety property — a misconfigured prefix
|
|
3626
|
-
* list can never erase content pulled from elsewhere.
|
|
3627
|
-
* 3. The key is NOT already in `alreadyPlanned` (the standard delete
|
|
3628
|
-
* plan), so a single DeleteObject + journal-update pass processes
|
|
3629
|
-
* each key once.
|
|
3630
|
-
*
|
|
3631
|
-
* Empty `prefixes` ⇒ empty plan (caller didn't opt in).
|
|
3632
|
-
*/
|
|
3633
|
-
function computeDecommissionPlan(
|
|
3634
|
-
journal: SyncJournal,
|
|
3635
|
-
prefixes: string[],
|
|
3636
|
-
policy: "currency-gated" | "owned-only" | "all",
|
|
3637
|
-
alreadyPlanned: ReadonlySet<string>,
|
|
3638
|
-
): string[] {
|
|
3639
|
-
if (prefixes.length === 0) return [];
|
|
3640
|
-
// `currency-gated` exists to gate `propagateDeletes` on per-file proof of
|
|
3641
|
-
// currency (HEAD compare). Decommission is unconditional by design — we
|
|
3642
|
-
// don't need a HEAD check to know whether the key belongs here, the
|
|
3643
|
-
// caller has asserted it doesn't. So for the direction-of-origin safety
|
|
3644
|
-
// gate we collapse `currency-gated` to the `owned-only` behavior:
|
|
3645
|
-
// peer-written entries (direction:'down') are skipped. That's the
|
|
3646
|
-
// conservative choice — a peer who wrote into this bucket should
|
|
3647
|
-
// reconcile their own entry, not have us blast it away on their behalf.
|
|
3648
|
-
const requireOwned = policy === "owned-only" || policy === "currency-gated";
|
|
3649
|
-
const out: string[] = [];
|
|
3650
|
-
for (const [relativeKey, entry] of Object.entries(journal.files)) {
|
|
3651
|
-
if (alreadyPlanned.has(relativeKey)) continue;
|
|
3652
|
-
const matches = prefixes.some(
|
|
3653
|
-
(prefix) =>
|
|
3654
|
-
prefix === "" ||
|
|
3655
|
-
relativeKey === prefix ||
|
|
3656
|
-
relativeKey.startsWith(`${prefix}/`),
|
|
3657
|
-
);
|
|
3658
|
-
if (!matches) continue;
|
|
3659
|
-
if (requireOwned && entry.direction !== "up") continue;
|
|
3660
|
-
out.push(relativeKey);
|
|
3661
|
-
}
|
|
3662
|
-
return out;
|
|
3663
|
-
}
|