@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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 +16 -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 +165 -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 +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -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 +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -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/journal.ts
DELETED
|
@@ -1,765 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sync journal — tracks per-file state (hash, size, last-synced direction) so
|
|
3
|
-
* sync/share can detect local edits that would be clobbered by a blind pull.
|
|
4
|
-
*
|
|
5
|
-
* ADR-0001 Phase 5: the journal is sharded by company slug and lives in
|
|
6
|
-
* `~/.hq/`, not inside the HQ content root. One monolithic journal per HQ
|
|
7
|
-
* install conflates state across companies and forces every runner to
|
|
8
|
-
* serialize through the same file — splitting it lets `hq-sync-runner
|
|
9
|
-
* --companies` fan out without contention, and a corrupted shard only affects
|
|
10
|
-
* one company.
|
|
11
|
-
*
|
|
12
|
-
* Path: `{stateDir}/sync-journal.{slug}.json`, where `stateDir` resolves to
|
|
13
|
-
* `HQ_STATE_DIR` (if set) or `~/.hq`.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import * as fs from "fs";
|
|
17
|
-
import * as os from "os";
|
|
18
|
-
import * as path from "path";
|
|
19
|
-
import * as crypto from "crypto";
|
|
20
|
-
import type { SyncJournal, JournalEntry, PullRecord, V3JournalState, V3JournalWalPayload } from "./types.js";
|
|
21
|
-
import { toPosixKey } from "./s3.js";
|
|
22
|
-
import { StateStore } from "./sync/state-store.js";
|
|
23
|
-
|
|
24
|
-
/** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
|
|
25
|
-
export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
26
|
-
|
|
27
|
-
/** Current journal schema version written by all v2-aware writers. */
|
|
28
|
-
export const JOURNAL_VERSION_CURRENT = "2" as const;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Retain bounded pull history per company. Scope-shrink logic only needs the
|
|
32
|
-
* newest record, but a small tail keeps diagnostics useful without letting
|
|
33
|
-
* long-running sync loops grow journals forever.
|
|
34
|
-
*/
|
|
35
|
-
export const MAX_PULLS_PER_COMPANY = 50;
|
|
36
|
-
|
|
37
|
-
const JOURNAL_FILE_PREFIX = "sync-journal.";
|
|
38
|
-
const JOURNAL_FILE_SUFFIX = ".json";
|
|
39
|
-
const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
|
|
40
|
-
const JOURNAL_WAL_RECORD_TYPE = 1;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
|
|
45
|
-
* non-standard installs; otherwise falls back to `~/.hq`.
|
|
46
|
-
*/
|
|
47
|
-
export function getStateDir(): string {
|
|
48
|
-
return process.env.HQ_STATE_DIR ?? path.join(os.homedir(), ".hq");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Filename-safe form of a slug. Slugs from vault-service are already
|
|
53
|
-
* URL-safe, but this guards against paths, dots, or anything the filesystem
|
|
54
|
-
* might interpret. Empty-or-invalid slugs throw rather than silently writing
|
|
55
|
-
* to a shared "sync-journal..json" file.
|
|
56
|
-
*/
|
|
57
|
-
function sanitizeSlug(slug: string): string {
|
|
58
|
-
if (!slug) {
|
|
59
|
-
throw new Error("journal: slug is required (empty or undefined)");
|
|
60
|
-
}
|
|
61
|
-
const cleaned = slug.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
62
|
-
if (!cleaned || /^[_-]+$/.test(cleaned)) {
|
|
63
|
-
throw new Error(`journal: slug "${slug}" sanitizes to an empty identifier`);
|
|
64
|
-
}
|
|
65
|
-
return cleaned;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function getJournalPath(slug: string): string {
|
|
69
|
-
return path.join(
|
|
70
|
-
getStateDir(),
|
|
71
|
-
`${JOURNAL_FILE_PREFIX}${sanitizeSlug(slug)}${JOURNAL_FILE_SUFFIX}`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Reserved journal slug for the personal-vault fanout slot in the `--companies`
|
|
77
|
-
* runner. The vault slot uploads the whole HQ overlay (`.claude/`, `core/`,
|
|
78
|
-
* `personal/`, …) and journals hq-root-relative keys; its `syncRoot` is the HQ
|
|
79
|
-
* root itself.
|
|
80
|
-
*
|
|
81
|
-
* It MUST NOT share a journal with any real cloud company. Previously the slot
|
|
82
|
-
* used the literal slug `"personal"`, which collided with the
|
|
83
|
-
* `companies/personal` company (whose entity slug is also `"personal"`). The
|
|
84
|
-
* two targets have different sync roots, so the company's whole-tree
|
|
85
|
-
* `computeDeletePlan` walked the shared `sync-journal.personal.json`, resolved
|
|
86
|
-
* the vault's hq-root keys against `hqRoot/companies/personal` (where they
|
|
87
|
-
* don't exist), tombstoned them as "remote already 404", and dropped them from
|
|
88
|
-
* the journal — only for the vault slot to re-upload them next cycle. ~190
|
|
89
|
-
* `.claude/skills/*` files churned every sync.
|
|
90
|
-
*
|
|
91
|
-
* This sentinel value can never be produced by a real company slug from the
|
|
92
|
-
* entity service (which yields URL-safe lowercase slugs without leading
|
|
93
|
-
* underscores), and it survives `sanitizeSlug` unchanged (only `[a-zA-Z0-9_-]`
|
|
94
|
-
* chars; the embedded letters keep it off the all-`[_-]` reject path).
|
|
95
|
-
*/
|
|
96
|
-
export const PERSONAL_VAULT_JOURNAL_SLUG = "__hq_personal_vault__";
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* One-time seed migration for the personal-vault journal slug.
|
|
100
|
-
*
|
|
101
|
-
* Before this fix the personal-vault slot journaled under the slug
|
|
102
|
-
* `"personal"`. After the fix it journals under
|
|
103
|
-
* `PERSONAL_VAULT_JOURNAL_SLUG`. Without a seed, the first run under the new
|
|
104
|
-
* slug would start from an empty journal and re-upload the entire HQ overlay.
|
|
105
|
-
*
|
|
106
|
-
* To avoid that mass re-upload, this copies the legacy `sync-journal.personal.json`
|
|
107
|
-
* to `sync-journal.__hq_personal_vault__.json` exactly once: only when the new
|
|
108
|
-
* file does NOT exist and the legacy file DOES. Idempotent — a no-op when the
|
|
109
|
-
* new file already exists or the legacy file is absent.
|
|
110
|
-
*
|
|
111
|
-
* The legacy `personal` journal is left untouched (it is still the journal for
|
|
112
|
-
* the real `companies/personal` company). After the seed, both journals
|
|
113
|
-
* converge after one cleanup cycle: the legacy `personal` journal tombstones
|
|
114
|
-
* the now-foreign hq-root keys once; the new vault journal tombstones any
|
|
115
|
-
* companies/personal-relative keys once. That single convergence pass is
|
|
116
|
-
* expected and harmless.
|
|
117
|
-
*/
|
|
118
|
-
export function migratePersonalVaultJournal(): void {
|
|
119
|
-
const newPath = getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
120
|
-
if (fs.existsSync(newPath)) return;
|
|
121
|
-
const legacyPath = getJournalPath("personal");
|
|
122
|
-
if (!fs.existsSync(legacyPath)) return;
|
|
123
|
-
const legacy = readJournal("personal");
|
|
124
|
-
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, legacy);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Read a per-company journal from disk.
|
|
129
|
-
*
|
|
130
|
-
* Back-compat (US-005, v1 → v2): a v1 file on disk is returned as-is with
|
|
131
|
-
* `version: "1"` and no `pulls` field. The in-place migration to v2 happens
|
|
132
|
-
* the first time `writeJournal` runs — `migrateToV2` ensures any journal
|
|
133
|
-
* passed to the writer carries `version: "2"`, `pulls: []`, and the rest of
|
|
134
|
-
* the v2 shape. This keeps `readJournal` deterministic + cheap and confines
|
|
135
|
-
* the side effect (schema bump on disk) to writes.
|
|
136
|
-
*
|
|
137
|
-
* When the file doesn't exist, we return a fresh v2 journal directly — new
|
|
138
|
-
* installs never pass through v1 on disk.
|
|
139
|
-
*/
|
|
140
|
-
export function readJournal(slug: string): SyncJournal {
|
|
141
|
-
if (StateStore.exists(getStateDir(), journalScopeId(slug))) {
|
|
142
|
-
return openJournalStore(slug, emptyJournal()).getState().journal;
|
|
143
|
-
}
|
|
144
|
-
const journalPath = getJournalPath(slug);
|
|
145
|
-
let legacy: SyncJournal;
|
|
146
|
-
if (fs.existsSync(journalPath)) {
|
|
147
|
-
legacy = readJournalFileWithLastGood(journalPath);
|
|
148
|
-
} else {
|
|
149
|
-
legacy = emptyJournal();
|
|
150
|
-
}
|
|
151
|
-
migrateToV2(legacy);
|
|
152
|
-
return openJournalStore(slug, legacy).getState().journal;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function emptyJournal(): SyncJournal {
|
|
156
|
-
return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function journalScopeId(slug: string): string {
|
|
160
|
-
return `journal:${sanitizeSlug(slug)}`;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function openJournalStore(slug: string, initialJournal: SyncJournal): StateStore<V3JournalState> {
|
|
164
|
-
return StateStore.open({
|
|
165
|
-
rootDir: getStateDir(),
|
|
166
|
-
scopeId: journalScopeId(slug),
|
|
167
|
-
initialState: { journal: initialJournal },
|
|
168
|
-
reduce: (state, record) => {
|
|
169
|
-
if (record.type !== JOURNAL_WAL_RECORD_TYPE) {
|
|
170
|
-
throw new Error(`journal: unexpected v3 WAL record type ${record.type}`);
|
|
171
|
-
}
|
|
172
|
-
const payload = record.payload as Partial<V3JournalWalPayload>;
|
|
173
|
-
if (!payload || typeof payload !== "object" || !isJournal(payload.journal)) {
|
|
174
|
-
throw new Error("journal: v3 WAL record has an invalid journal payload");
|
|
175
|
-
}
|
|
176
|
-
return { ...state, journal: payload.journal };
|
|
177
|
-
},
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function isJournal(value: unknown): value is SyncJournal {
|
|
182
|
-
return typeof value === "object" && value !== null &&
|
|
183
|
-
((value as SyncJournal).version === "1" || (value as SyncJournal).version === "2") &&
|
|
184
|
-
typeof (value as SyncJournal).lastSync === "string" &&
|
|
185
|
-
typeof (value as SyncJournal).files === "object" &&
|
|
186
|
-
(value as SyncJournal).files !== null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function parseJournalContent(content: string): SyncJournal {
|
|
190
|
-
if (content.length === 0) {
|
|
191
|
-
throw new SyntaxError("journal JSON is empty");
|
|
192
|
-
}
|
|
193
|
-
return JSON.parse(content) as SyncJournal;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function isCorruptJournalJson(err: unknown): boolean {
|
|
197
|
-
return err instanceof SyntaxError;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function readJournalFileWithLastGood(journalPath: string): SyncJournal {
|
|
201
|
-
let primaryErr: unknown;
|
|
202
|
-
try {
|
|
203
|
-
return parseJournalContent(fs.readFileSync(journalPath, "utf-8"));
|
|
204
|
-
} catch (err) {
|
|
205
|
-
if (!isCorruptJournalJson(err)) throw err;
|
|
206
|
-
primaryErr = err;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
return parseJournalContent(
|
|
211
|
-
fs.readFileSync(lastGoodJournalPath(journalPath), "utf-8"),
|
|
212
|
-
);
|
|
213
|
-
} catch {
|
|
214
|
-
throw primaryErr;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** One enumerated journal shard: its recovered slug, on-disk path, contents. */
|
|
219
|
-
export interface JournalSummary {
|
|
220
|
-
/**
|
|
221
|
-
* Slug recovered from the `sync-journal.<slug>.json` filename — the
|
|
222
|
-
* sanitized form the engine wrote (e.g. a company slug,
|
|
223
|
-
* `PERSONAL_VAULT_JOURNAL_SLUG`, or the legacy `"personal"`).
|
|
224
|
-
*/
|
|
225
|
-
slug: string;
|
|
226
|
-
/** Absolute path to the journal file. */
|
|
227
|
-
path: string;
|
|
228
|
-
/** Parsed journal contents. */
|
|
229
|
-
journal: SyncJournal;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Enumerate every sync journal present in the state dir.
|
|
234
|
-
*
|
|
235
|
-
* The engine SHARDS journals by slug (ADR-0001 Phase 5): the personal-vault
|
|
236
|
-
* fanout slot under `PERSONAL_VAULT_JOURNAL_SLUG`, one shard per cloud company,
|
|
237
|
-
* and the legacy `"personal"` shard. A caller that reads a single fixed path
|
|
238
|
-
* therefore only ever sees one scope — and a caller that mistakes a non-slug
|
|
239
|
-
* value for a slug (e.g. `hq sync status` passing the HQ-root PATH, which
|
|
240
|
-
* `sanitizeSlug` mangles into `_Users_<user>_hq`) sees a slug the engine never
|
|
241
|
-
* writes, and reports "no journal" right after a successful sync. Any surface
|
|
242
|
-
* that wants the COMPLETE local sync picture must read ALL shards via this
|
|
243
|
-
* helper rather than reconstructing a path.
|
|
244
|
-
*
|
|
245
|
-
* Slugs are recovered from each filename. A shard that fails to read or parse
|
|
246
|
-
* is skipped rather than thrown — one corrupt shard must not blind the caller
|
|
247
|
-
* to the healthy ones. Results are sorted by slug for deterministic output.
|
|
248
|
-
*/
|
|
249
|
-
export function listJournals(): JournalSummary[] {
|
|
250
|
-
const dir = getStateDir();
|
|
251
|
-
let names: string[];
|
|
252
|
-
try {
|
|
253
|
-
names = fs.readdirSync(dir);
|
|
254
|
-
} catch {
|
|
255
|
-
return []; // state dir absent → no journals yet
|
|
256
|
-
}
|
|
257
|
-
const out: JournalSummary[] = [];
|
|
258
|
-
for (const name of names) {
|
|
259
|
-
if (
|
|
260
|
-
!name.startsWith(JOURNAL_FILE_PREFIX) ||
|
|
261
|
-
!name.endsWith(JOURNAL_FILE_SUFFIX)
|
|
262
|
-
) {
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
const slug = name.slice(
|
|
266
|
-
JOURNAL_FILE_PREFIX.length,
|
|
267
|
-
name.length - JOURNAL_FILE_SUFFIX.length,
|
|
268
|
-
);
|
|
269
|
-
if (!slug) continue; // guard against a stray "sync-journal..json"
|
|
270
|
-
const filePath = path.join(dir, name);
|
|
271
|
-
try {
|
|
272
|
-
// Prefer the v3 store when this legacy filename is only its immutable
|
|
273
|
-
// compatibility locator; otherwise this performs the v2 last-good read.
|
|
274
|
-
const journal = readJournal(slug);
|
|
275
|
-
out.push({ slug, path: filePath, journal });
|
|
276
|
-
} catch (err) {
|
|
277
|
-
if (!isCorruptJournalJson(err)) throw err;
|
|
278
|
-
// Corrupt shard with no usable last-good — skip; don't blind the caller
|
|
279
|
-
// to the rest. Genuine IO/permission failures surface to the caller.
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
out.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
283
|
-
return out;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Defuse the pre-5.47.2 Windows backslash-key landmine in a journal's `files`
|
|
288
|
-
* map. Such clients stamped keys with the OS path separator ("\\"), e.g.
|
|
289
|
-
* `projects\\forecast-development\\x.csv`. A backslash key is a live data-loss
|
|
290
|
-
* hazard for the cross-machine delete planner (Bug #9): it never matches the
|
|
291
|
-
* forward-slash remote LIST, so the planner classifies the still-present local
|
|
292
|
-
* file as remote-deleted, and `path.join(companyRoot, key)` collapses the
|
|
293
|
-
* backslashes back onto the REAL POSIX file — which the executor then unlinks
|
|
294
|
-
* (ridge incident, feedback_b8d09d0f: a single pull deleted ~36 live files,
|
|
295
|
-
* with 587 backslash keys left in the journal as a recurring landmine).
|
|
296
|
-
*
|
|
297
|
-
* Rewriting every key to its canonical POSIX form on load removes the hazard
|
|
298
|
-
* idempotently — a clean (all-POSIX) journal is returned untouched. Merge rule:
|
|
299
|
-
* if a key's POSIX twin already exists, the POSIX entry is authoritative (it
|
|
300
|
-
* round-tripped through an up-to-date client) and the malformed duplicate is
|
|
301
|
-
* dropped; otherwise the entry is moved to its POSIX key. Returns the number of
|
|
302
|
-
* keys rewritten (0 for a clean journal) for telemetry and test assertions.
|
|
303
|
-
*/
|
|
304
|
-
export function normalizeJournalKeys(journal: SyncJournal): number {
|
|
305
|
-
if (!journal.files) return 0;
|
|
306
|
-
let rewritten = 0;
|
|
307
|
-
// Snapshot keys up front — we mutate journal.files during the walk.
|
|
308
|
-
for (const key of Object.keys(journal.files)) {
|
|
309
|
-
const posix = toPosixKey(key);
|
|
310
|
-
if (posix === key) continue; // already canonical (no backslash)
|
|
311
|
-
const entry = journal.files[key];
|
|
312
|
-
delete journal.files[key];
|
|
313
|
-
rewritten++;
|
|
314
|
-
// POSIX twin already present → it wins; drop the malformed duplicate.
|
|
315
|
-
if (!(posix in journal.files)) {
|
|
316
|
-
journal.files[posix] = entry;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
return rewritten;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Coerce any-version journal into a v2 shape. Idempotent for v2 inputs.
|
|
324
|
-
* Mutates the input and returns it for chainable use. Call this immediately
|
|
325
|
-
* after `readJournal` if your code-path needs the v2 fields.
|
|
326
|
-
*
|
|
327
|
-
* v1 → v2 contract: every existing `files[]` entry is preserved as-is; no
|
|
328
|
-
* tombstone fields are inserted (legacy entries are NOT scope-shrink
|
|
329
|
-
* tombstones). `pulls` becomes `[]` (empty history → treat last scope as
|
|
330
|
-
* "all" in the scope-shrink algorithm).
|
|
331
|
-
*/
|
|
332
|
-
export function migrateToV2(journal: SyncJournal): SyncJournal {
|
|
333
|
-
// Backslash-key normalization runs UNCONDITIONALLY — a poisoned journal can
|
|
334
|
-
// already carry version "2" (the landmine predates the schema bump), so this
|
|
335
|
-
// must precede the v2 early-return or already-v2 journals stay poisoned.
|
|
336
|
-
normalizeJournalKeys(journal);
|
|
337
|
-
if (journal.version === "2" && Array.isArray(journal.pulls)) {
|
|
338
|
-
return journal;
|
|
339
|
-
}
|
|
340
|
-
journal.version = JOURNAL_VERSION_CURRENT;
|
|
341
|
-
if (!Array.isArray(journal.pulls)) journal.pulls = [];
|
|
342
|
-
return journal;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Write a journal to disk, migrating to the current schema version in-place.
|
|
347
|
-
* `migrateToV2` mutates the passed-in object — callers that hold a reference
|
|
348
|
-
* after the write will see the v2 shape.
|
|
349
|
-
*/
|
|
350
|
-
export function writeJournal(slug: string, journal: SyncJournal): void {
|
|
351
|
-
migrateToV2(journal);
|
|
352
|
-
const store = openJournalStore(slug, journal);
|
|
353
|
-
// The initial v2 → v3 migration is a durable snapshot. Every normal write
|
|
354
|
-
// thereafter is one framed append + fdatasync, never a whole-journal rewrite.
|
|
355
|
-
if (store.wasCreated) {
|
|
356
|
-
writeLegacyJournalMirror(slug, journal);
|
|
357
|
-
} else {
|
|
358
|
-
store.append(JOURNAL_WAL_RECORD_TYPE, { journal });
|
|
359
|
-
// The JSON file remains a compatibility mirror for shipped CLI/status
|
|
360
|
-
// consumers that have not moved to StateStore yet. It is never used for
|
|
361
|
-
// recovery once a v3 generation exists; the framed WAL above is the sole
|
|
362
|
-
// authority and hot path. Keep the mirror current so those consumers do
|
|
363
|
-
// not observe stale sync state during the phased migration.
|
|
364
|
-
writeLegacyJournalMirror(slug, journal);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Keep the v2-shaped filename as a compatibility mirror for older CLI/status
|
|
370
|
-
* surfaces. The v3 state store is authoritative after migration; this mirror
|
|
371
|
-
* is atomically replaced only to preserve existing readers until their owner
|
|
372
|
-
* migrates them. WAL recovery never consults it once a v3 generation exists.
|
|
373
|
-
*/
|
|
374
|
-
function writeLegacyJournalMirror(slug: string, journal: SyncJournal): void {
|
|
375
|
-
const journalPath = getJournalPath(slug);
|
|
376
|
-
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
|
377
|
-
const temporary = path.join(
|
|
378
|
-
path.dirname(journalPath),
|
|
379
|
-
`.${path.basename(journalPath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`,
|
|
380
|
-
);
|
|
381
|
-
let fd: number | undefined;
|
|
382
|
-
try {
|
|
383
|
-
fd = fs.openSync(temporary, "wx");
|
|
384
|
-
fs.writeFileSync(fd, JSON.stringify(journal));
|
|
385
|
-
fs.fdatasyncSync(fd);
|
|
386
|
-
fs.closeSync(fd);
|
|
387
|
-
fd = undefined;
|
|
388
|
-
fs.renameSync(temporary, journalPath);
|
|
389
|
-
} catch (error) {
|
|
390
|
-
try { fs.rmSync(temporary, { force: true }); } catch { /* preserve mirror write failure */ }
|
|
391
|
-
throw new Error(`journal: failed to update v3 compatibility mirror ${journalPath}`, { cause: error });
|
|
392
|
-
} finally {
|
|
393
|
-
if (fd !== undefined) fs.closeSync(fd);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function lastGoodJournalPath(journalPath: string): string {
|
|
398
|
-
return `${journalPath}${JOURNAL_LAST_GOOD_SUFFIX}`;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
export function hashFile(filePath: string): string {
|
|
403
|
-
const content = fs.readFileSync(filePath);
|
|
404
|
-
return crypto.createHash("sha256").update(content).digest("hex");
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Marker prepended to a symlink's target string before hashing for the
|
|
409
|
-
* journal. Mirrors the wire-side `SYMLINK_BODY_PREFIX` constant in
|
|
410
|
-
* `s3.ts` — same purpose, different namespace.
|
|
411
|
-
*
|
|
412
|
-
* Without this marker, a symlink to `real.md` and a regular file whose
|
|
413
|
-
* contents are exactly the bytes `real.md` produce identical journal
|
|
414
|
-
* hashes (both `sha256("real.md")`). When `skipUnchanged` is enabled,
|
|
415
|
-
* the planner would treat a regular-file → symlink replacement as
|
|
416
|
-
* "no change" and never upload the new symlink, leaving the remote
|
|
417
|
-
* representation stale forever — the pull side would then also see no
|
|
418
|
-
* drift via ETag and never repair.
|
|
419
|
-
*
|
|
420
|
-
* Hashing `sha256(prefix + target)` makes the two representations
|
|
421
|
-
* structurally inequal in journal-hash space, so skip-unchanged can
|
|
422
|
-
* never confuse them. The hash always varies with the target string,
|
|
423
|
-
* so target rewrites still re-fire uploads as expected.
|
|
424
|
-
*/
|
|
425
|
-
export const SYMLINK_HASH_PREFIX = "hq-symlink:";
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Compute the journal hash for a symlink. Always use this helper
|
|
429
|
-
* (never inline `crypto.createHash` with the raw target) so the
|
|
430
|
-
* push side, the pull-planner, and the post-download stamp stay in
|
|
431
|
-
* lockstep on the prefixed-hash convention.
|
|
432
|
-
*/
|
|
433
|
-
export function hashSymlinkTarget(target: string): string {
|
|
434
|
-
return crypto
|
|
435
|
-
.createHash("sha256")
|
|
436
|
-
.update(SYMLINK_HASH_PREFIX + target)
|
|
437
|
-
.digest("hex");
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Record a per-file journal entry after a transfer.
|
|
442
|
-
*
|
|
443
|
-
* `direction` (`"up"` = pushed / locally authored, `"down"` = pulled) is what
|
|
444
|
-
* lets scope-shrink tell your own work apart from a mirror of someone else's.
|
|
445
|
-
* It underpins the US-006 push-only sessions contract: a session transcript
|
|
446
|
-
* this machine authored and pushed is stamped `direction:"up"`, so even though
|
|
447
|
-
* `sessions/` is excluded from every pull scope, `buildScopeShrinkPlan` skips
|
|
448
|
-
* `direction:"up"` entries and never orphans it. (Sessions fetched on demand
|
|
449
|
-
* via `hq files get` are pulled — `direction:"down"` — but ride the pin union
|
|
450
|
-
* in the caller's inclusion prefixSet, so they are likewise never pruned.)
|
|
451
|
-
*/
|
|
452
|
-
/**
|
|
453
|
-
* Thrown when a caller tries to journal a path that is not on disk.
|
|
454
|
-
*
|
|
455
|
-
* Deliberately loud rather than a silent skip: a caller reaching this has a
|
|
456
|
-
* real ordering bug, and swallowing it would reintroduce exactly the silent
|
|
457
|
-
* drift this guard exists to prevent.
|
|
458
|
-
*/
|
|
459
|
-
export class PrematureJournalEntryError extends Error {
|
|
460
|
-
constructor(readonly relativePath: string, readonly absolutePath: string) {
|
|
461
|
-
super(
|
|
462
|
-
`refusing to journal '${relativePath}': no file at '${absolutePath}'. ` +
|
|
463
|
-
`A journal entry must be written only AFTER its file is on disk.`,
|
|
464
|
-
);
|
|
465
|
-
this.name = "PrematureJournalEntryError";
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Optional fields of a journal entry.
|
|
471
|
-
*
|
|
472
|
-
* These are an OBJECT rather than trailing positional parameters because they
|
|
473
|
-
* were four adjacent optionals, two of them `string`, and callers drifted:
|
|
474
|
-
* two conflict-resolution sites in `cli/sync.ts` passed their `"file" |
|
|
475
|
-
* "symlink"` value into the `createdBySub` slot, so `entry.kind` was silently
|
|
476
|
-
* never set on any conflict-resolved entry. That is not cosmetic — `entry.kind`
|
|
477
|
-
* gates delete-intent minting (`markLocalDeleteIntent` requires it to match,
|
|
478
|
-
* and the watcher skips any entry without one), so a file that had ever been
|
|
479
|
-
* through conflict resolution could never be deleted, even with a live watcher.
|
|
480
|
-
* Named fields make that class of mistake unrepresentable.
|
|
481
|
-
*/
|
|
482
|
-
export interface UpdateEntryOptions {
|
|
483
|
-
remoteEtag?: string;
|
|
484
|
-
mtimeMs?: number;
|
|
485
|
-
/** Object's `created-by-sub` S3 metadata. Download path only. */
|
|
486
|
-
createdBySub?: string;
|
|
487
|
-
kind?: "file" | "symlink";
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Record a journal entry for a file that is CONFIRMED present on disk.
|
|
492
|
-
*
|
|
493
|
-
* `verifyAbsolutePath` is required, and the entry is written only after an
|
|
494
|
-
* `lstat` proves the file exists. This is a load-bearing invariant, not a
|
|
495
|
-
* defensive nicety: a journal entry says "this key was synced and the local
|
|
496
|
-
* copy is at this hash". Once delete propagation authorizes on ETag currency
|
|
497
|
-
* alone (no watcher-minted intent), an entry whose file was never actually
|
|
498
|
-
* written becomes indistinguishable from "the user deleted this file" — and
|
|
499
|
-
* the next push issues a remote DeleteObject for a file that was only ever
|
|
500
|
-
* premature bookkeeping.
|
|
501
|
-
*
|
|
502
|
-
* Every existing caller already had a `fs.lstatSync` in hand and passed values
|
|
503
|
-
* derived from it, so this makes an existing convention structural. The point
|
|
504
|
-
* is that a FUTURE caller cannot get it wrong: with the check inside this
|
|
505
|
-
* function there is no longer a way to add a call site that records an entry
|
|
506
|
-
* for a file that is not there.
|
|
507
|
-
*
|
|
508
|
-
* `lstat` (not `stat`) so a dangling symlink still counts as present — the
|
|
509
|
-
* link itself is the synced object, and its target may legitimately be absent.
|
|
510
|
-
*
|
|
511
|
-
* @throws PrematureJournalEntryError when nothing exists at `verifyAbsolutePath`.
|
|
512
|
-
*/
|
|
513
|
-
export function updateEntry(
|
|
514
|
-
journal: SyncJournal,
|
|
515
|
-
relativePath: string,
|
|
516
|
-
hash: string,
|
|
517
|
-
size: number,
|
|
518
|
-
direction: "up" | "down",
|
|
519
|
-
verifyAbsolutePath: string,
|
|
520
|
-
opts: UpdateEntryOptions = {},
|
|
521
|
-
): void {
|
|
522
|
-
try {
|
|
523
|
-
fs.lstatSync(verifyAbsolutePath);
|
|
524
|
-
} catch {
|
|
525
|
-
throw new PrematureJournalEntryError(relativePath, verifyAbsolutePath);
|
|
526
|
-
}
|
|
527
|
-
const { remoteEtag, mtimeMs, createdBySub, kind } = opts;
|
|
528
|
-
const entry: JournalEntry = {
|
|
529
|
-
hash,
|
|
530
|
-
size,
|
|
531
|
-
syncedAt: new Date().toISOString(),
|
|
532
|
-
direction,
|
|
533
|
-
};
|
|
534
|
-
if (remoteEtag !== undefined && remoteEtag !== "") {
|
|
535
|
-
entry.remoteEtag = normalizeEtag(remoteEtag);
|
|
536
|
-
}
|
|
537
|
-
if (mtimeMs !== undefined) {
|
|
538
|
-
entry.mtimeMs = mtimeMs;
|
|
539
|
-
}
|
|
540
|
-
// Authorship (scope-shrink guard input). Stamped from the object's
|
|
541
|
-
// `created-by-sub` S3 metadata on download. Only persisted when present so
|
|
542
|
-
// legacy journals and author-less uploads stay byte-identical.
|
|
543
|
-
if (createdBySub !== undefined && createdBySub !== "") {
|
|
544
|
-
entry.createdBySub = createdBySub;
|
|
545
|
-
}
|
|
546
|
-
if (kind !== undefined) {
|
|
547
|
-
entry.kind = kind;
|
|
548
|
-
}
|
|
549
|
-
journal.files[relativePath] = entry;
|
|
550
|
-
journal.lastSync = new Date().toISOString();
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* S3 returns ETags wrapped in literal double-quotes (e.g. `"d41d8cd9..."`).
|
|
555
|
-
* Strip them so equality comparisons across HEAD / GET / PUT responses are
|
|
556
|
-
* stable regardless of which AWS SDK call surfaced the value.
|
|
557
|
-
*/
|
|
558
|
-
export function normalizeEtag(etag: string): string {
|
|
559
|
-
if (!etag) return "";
|
|
560
|
-
return etag.replace(/^"|"$/g, "");
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
export function getEntry(
|
|
564
|
-
journal: SyncJournal,
|
|
565
|
-
relativePath: string,
|
|
566
|
-
): JournalEntry | undefined {
|
|
567
|
-
return journal.files[relativePath];
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
export function removeEntry(
|
|
571
|
-
journal: SyncJournal,
|
|
572
|
-
relativePath: string,
|
|
573
|
-
): void {
|
|
574
|
-
delete journal.files[relativePath];
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// ─── Journal v2 (US-005): pulls, tombstones, GC ─────────────────────────────
|
|
578
|
-
|
|
579
|
-
/** Crockford base32 alphabet (ULID-compatible). */
|
|
580
|
-
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* Generate a ULID-shaped 26-char identifier without adding a runtime dep.
|
|
584
|
-
* Format: 10-char base32 of the current millisecond timestamp + 16-char
|
|
585
|
-
* base32 of random bytes. Lexically sortable, time-prefixed — same property
|
|
586
|
-
* that makes ULIDs useful for `pulls[]` ordering.
|
|
587
|
-
*
|
|
588
|
-
* We don't need full ULID spec compliance (monotonic counter, randomness
|
|
589
|
-
* spec) — just sortable + collision-resistant enough that two pulls
|
|
590
|
-
* issued in the same millisecond by different processes don't clash.
|
|
591
|
-
* 80 bits of randomness is plenty.
|
|
592
|
-
*/
|
|
593
|
-
export function generatePullId(now: number = Date.now()): string {
|
|
594
|
-
let time = now;
|
|
595
|
-
const timeChars: string[] = [];
|
|
596
|
-
for (let i = 0; i < 10; i++) {
|
|
597
|
-
timeChars.unshift(CROCKFORD[time % 32]!);
|
|
598
|
-
time = Math.floor(time / 32);
|
|
599
|
-
}
|
|
600
|
-
const randBytes = crypto.randomBytes(10); // 80 bits
|
|
601
|
-
const randChars: string[] = [];
|
|
602
|
-
// Encode 10 random bytes (80 bits) into 16 base32 chars.
|
|
603
|
-
let buf = 0;
|
|
604
|
-
let bits = 0;
|
|
605
|
-
for (let i = 0; i < randBytes.length; i++) {
|
|
606
|
-
buf = (buf << 8) | randBytes[i]!;
|
|
607
|
-
bits += 8;
|
|
608
|
-
while (bits >= 5) {
|
|
609
|
-
bits -= 5;
|
|
610
|
-
randChars.push(CROCKFORD[(buf >> bits) & 0x1f]!);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
if (bits > 0) {
|
|
614
|
-
randChars.push(CROCKFORD[(buf << (5 - bits)) & 0x1f]!);
|
|
615
|
-
}
|
|
616
|
-
return timeChars.join("") + randChars.slice(0, 16).join("");
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Find the most-recent `PullRecord` for a company in the journal. Returns
|
|
621
|
-
* `undefined` when no record exists — scope-shrink callers treat that as
|
|
622
|
-
* "no prior scope; nothing to shrink".
|
|
623
|
-
*
|
|
624
|
-
* Order by `completedAt` descending — `pullId` is lexically sortable but
|
|
625
|
-
* `completedAt` is what semantically represents "most recent successful
|
|
626
|
-
* pull state at last close".
|
|
627
|
-
*/
|
|
628
|
-
export function lastPullRecord(
|
|
629
|
-
journal: SyncJournal,
|
|
630
|
-
companyUid: string,
|
|
631
|
-
): PullRecord | undefined {
|
|
632
|
-
if (!journal.pulls || journal.pulls.length === 0) return undefined;
|
|
633
|
-
let best: PullRecord | undefined;
|
|
634
|
-
for (const p of journal.pulls) {
|
|
635
|
-
if (p.companyUid !== companyUid) continue;
|
|
636
|
-
if (!best || p.completedAt > best.completedAt) best = p;
|
|
637
|
-
}
|
|
638
|
-
return best;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
function trimPullRecords(journal: SyncJournal): void {
|
|
642
|
-
const pulls = journal.pulls;
|
|
643
|
-
if (!pulls || pulls.length === 0) return;
|
|
644
|
-
|
|
645
|
-
const byCompany = new Map<string, Array<{ record: PullRecord; index: number }>>();
|
|
646
|
-
pulls.forEach((record, index) => {
|
|
647
|
-
const entries = byCompany.get(record.companyUid);
|
|
648
|
-
if (entries) entries.push({ record, index });
|
|
649
|
-
else byCompany.set(record.companyUid, [{ record, index }]);
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
const keep = new Set<number>();
|
|
653
|
-
for (const entries of byCompany.values()) {
|
|
654
|
-
entries
|
|
655
|
-
.sort((a, b) => {
|
|
656
|
-
const byCompletedAt = b.record.completedAt.localeCompare(
|
|
657
|
-
a.record.completedAt,
|
|
658
|
-
);
|
|
659
|
-
return byCompletedAt === 0 ? b.index - a.index : byCompletedAt;
|
|
660
|
-
})
|
|
661
|
-
.slice(0, MAX_PULLS_PER_COMPANY)
|
|
662
|
-
.forEach((entry) => keep.add(entry.index));
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
journal.pulls = pulls.filter((_, index) => keep.has(index));
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/** Append a `PullRecord` (mutates `journal.pulls`) and cap retained history. */
|
|
669
|
-
export function appendPullRecord(
|
|
670
|
-
journal: SyncJournal,
|
|
671
|
-
record: PullRecord,
|
|
672
|
-
): void {
|
|
673
|
-
migrateToV2(journal);
|
|
674
|
-
journal.pulls!.push(record);
|
|
675
|
-
trimPullRecords(journal);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Write a journal tombstone entry for `relativePath`. Used by the scope-
|
|
680
|
-
* shrink algorithm (US-005) and by `hq sync narrow --apply` (US-007).
|
|
681
|
-
*
|
|
682
|
-
* Tombstones intentionally keep the old `hash` / `size` / `syncedAt` /
|
|
683
|
-
* `direction` so a recovery flow could see what was there before pruning.
|
|
684
|
-
* They are GC'd after `TOMBSTONE_TTL_MS` via `gcTombstones`.
|
|
685
|
-
*/
|
|
686
|
-
export function tombstoneEntry(
|
|
687
|
-
journal: SyncJournal,
|
|
688
|
-
relativePath: string,
|
|
689
|
-
reason: "scope_shrink" | "narrow_apply" | "manual" | "local-delete",
|
|
690
|
-
now: string = new Date().toISOString(),
|
|
691
|
-
): void {
|
|
692
|
-
const entry = journal.files[relativePath];
|
|
693
|
-
if (!entry) return;
|
|
694
|
-
entry.removedAt = now;
|
|
695
|
-
entry.removedReason = reason;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Mark a journal entry for a user-authorized local delete. Callers must
|
|
700
|
-
* capture the local hash and kind before removing the object; an absent path
|
|
701
|
-
* alone is never deletion intent.
|
|
702
|
-
*/
|
|
703
|
-
export function markLocalDeleteIntent(
|
|
704
|
-
journal: SyncJournal,
|
|
705
|
-
relativePath: string,
|
|
706
|
-
localHash: string,
|
|
707
|
-
localKind: "file" | "symlink",
|
|
708
|
-
expectedRemoteEtag: string,
|
|
709
|
-
): boolean {
|
|
710
|
-
const entry = journal.files[relativePath];
|
|
711
|
-
if (
|
|
712
|
-
!entry?.remoteEtag ||
|
|
713
|
-
entry.remoteEtag !== normalizeEtag(expectedRemoteEtag) ||
|
|
714
|
-
entry.hash !== localHash ||
|
|
715
|
-
entry.kind !== localKind
|
|
716
|
-
) {
|
|
717
|
-
return false;
|
|
718
|
-
}
|
|
719
|
-
entry.localDeleteIntent = {
|
|
720
|
-
version: 1,
|
|
721
|
-
remoteEtag: entry.remoteEtag,
|
|
722
|
-
localHash,
|
|
723
|
-
localKind,
|
|
724
|
-
};
|
|
725
|
-
return true;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/** Invalidate a prior delete authorization after a watcher-observed recreate. */
|
|
729
|
-
export function clearLocalDeleteIntent(
|
|
730
|
-
journal: SyncJournal,
|
|
731
|
-
relativePath: string,
|
|
732
|
-
): boolean {
|
|
733
|
-
const entry = journal.files[relativePath];
|
|
734
|
-
if (!entry?.localDeleteIntent) return false;
|
|
735
|
-
delete entry.localDeleteIntent;
|
|
736
|
-
return true;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/** True if the entry is a tombstone (set by `tombstoneEntry`). */
|
|
740
|
-
export function isTombstone(entry: JournalEntry | undefined): boolean {
|
|
741
|
-
return !!entry && typeof entry.removedAt === "string";
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* Garbage-collect tombstones older than `TOMBSTONE_TTL_MS` from
|
|
746
|
-
* `journal.files`. Returns the number removed. Cheap — single pass over
|
|
747
|
-
* the files map, no I/O. Safe to call at the start AND end of every
|
|
748
|
-
* `pullAll` per-company leg; both runs are idempotent.
|
|
749
|
-
*/
|
|
750
|
-
export function gcTombstones(
|
|
751
|
-
journal: SyncJournal,
|
|
752
|
-
now: number = Date.now(),
|
|
753
|
-
): number {
|
|
754
|
-
let removed = 0;
|
|
755
|
-
for (const [path, entry] of Object.entries(journal.files)) {
|
|
756
|
-
if (!entry.removedAt) continue;
|
|
757
|
-
const removedTime = Date.parse(entry.removedAt);
|
|
758
|
-
if (Number.isNaN(removedTime)) continue;
|
|
759
|
-
if (now - removedTime > TOMBSTONE_TTL_MS) {
|
|
760
|
-
delete journal.files[path];
|
|
761
|
-
removed++;
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
return removed;
|
|
765
|
-
}
|