@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.test.ts
DELETED
|
@@ -1,968 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the sync journal (ADR-0001 Phase 5).
|
|
3
|
-
*
|
|
4
|
-
* Verifies per-company isolation, HQ_STATE_DIR override, and filename
|
|
5
|
-
* sanitization — all behaviors that the pre-Phase-5 monolithic journal
|
|
6
|
-
* didn't need.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
10
|
-
import * as fs from "fs";
|
|
11
|
-
import * as os from "os";
|
|
12
|
-
import * as path from "path";
|
|
13
|
-
import {
|
|
14
|
-
getJournalPath,
|
|
15
|
-
getStateDir,
|
|
16
|
-
readJournal,
|
|
17
|
-
writeJournal,
|
|
18
|
-
updateEntry,
|
|
19
|
-
PrematureJournalEntryError,
|
|
20
|
-
migrateToV2,
|
|
21
|
-
normalizeJournalKeys,
|
|
22
|
-
appendPullRecord,
|
|
23
|
-
lastPullRecord,
|
|
24
|
-
tombstoneEntry,
|
|
25
|
-
isTombstone,
|
|
26
|
-
gcTombstones,
|
|
27
|
-
generatePullId,
|
|
28
|
-
TOMBSTONE_TTL_MS,
|
|
29
|
-
MAX_PULLS_PER_COMPANY,
|
|
30
|
-
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
31
|
-
migratePersonalVaultJournal,
|
|
32
|
-
listJournals,
|
|
33
|
-
markLocalDeleteIntent,
|
|
34
|
-
} from "./journal.js";
|
|
35
|
-
import type { SyncJournal, PullRecord } from "./types.js";
|
|
36
|
-
|
|
37
|
-
describe("journal", () => {
|
|
38
|
-
let stateDir: string;
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-journal-test-"));
|
|
42
|
-
process.env.HQ_STATE_DIR = stateDir;
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
afterEach(() => {
|
|
46
|
-
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
47
|
-
delete process.env.HQ_STATE_DIR;
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("getStateDir", () => {
|
|
51
|
-
it("honors HQ_STATE_DIR env var", () => {
|
|
52
|
-
expect(getStateDir()).toBe(stateDir);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("falls back to ~/.hq when env var unset", () => {
|
|
56
|
-
delete process.env.HQ_STATE_DIR;
|
|
57
|
-
expect(getStateDir()).toBe(path.join(os.homedir(), ".hq"));
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe("getJournalPath", () => {
|
|
62
|
-
it("produces a per-slug filename", () => {
|
|
63
|
-
expect(getJournalPath("indigo")).toBe(
|
|
64
|
-
path.join(stateDir, "sync-journal.indigo.json"),
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("isolates different slugs into different files", () => {
|
|
69
|
-
expect(getJournalPath("indigo")).not.toBe(getJournalPath("brandstage"));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("sanitizes path-unsafe characters", () => {
|
|
73
|
-
expect(getJournalPath("foo/bar")).toBe(
|
|
74
|
-
path.join(stateDir, "sync-journal.foo_bar.json"),
|
|
75
|
-
);
|
|
76
|
-
expect(getJournalPath("../escape")).toBe(
|
|
77
|
-
path.join(stateDir, "sync-journal.___escape.json"),
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("throws on empty slug", () => {
|
|
82
|
-
expect(() => getJournalPath("")).toThrow(/slug is required/);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("throws on slug that sanitizes to empty", () => {
|
|
86
|
-
expect(() => getJournalPath("///")).toThrow(/empty identifier/);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("readJournal", () => {
|
|
91
|
-
it("returns an empty v2 journal when the file doesn't exist", () => {
|
|
92
|
-
const j = readJournal("indigo");
|
|
93
|
-
expect(j.version).toBe("2");
|
|
94
|
-
expect(j.files).toEqual({});
|
|
95
|
-
expect(j.lastSync).toBe("");
|
|
96
|
-
expect(j.pulls).toEqual([]);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("reads a journal written with writeJournal (round-trips through v2 migration)", () => {
|
|
100
|
-
// writeJournal upgrades the schema in-place to v2, so the read side
|
|
101
|
-
// sees v2 even if the caller handed a v1 shape.
|
|
102
|
-
const original: SyncJournal = {
|
|
103
|
-
version: "1",
|
|
104
|
-
lastSync: "2026-04-19T00:00:00.000Z",
|
|
105
|
-
files: {
|
|
106
|
-
"docs/handoff.md": {
|
|
107
|
-
hash: "abc123",
|
|
108
|
-
size: 42,
|
|
109
|
-
syncedAt: "2026-04-19T00:00:00.000Z",
|
|
110
|
-
direction: "down",
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
writeJournal("indigo", original);
|
|
115
|
-
const roundTripped = readJournal("indigo");
|
|
116
|
-
expect(roundTripped.version).toBe("2");
|
|
117
|
-
expect(roundTripped.lastSync).toBe("2026-04-19T00:00:00.000Z");
|
|
118
|
-
expect(roundTripped.files).toEqual(original.files);
|
|
119
|
-
expect(roundTripped.pulls).toEqual([]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("migrates a torn v2 primary from its last-good copy into the v3 state store", () => {
|
|
123
|
-
const lastGood: SyncJournal = {
|
|
124
|
-
version: "2",
|
|
125
|
-
lastSync: "2026-06-18T00:00:00.000Z",
|
|
126
|
-
files: {
|
|
127
|
-
"docs/stable.md": {
|
|
128
|
-
hash: "last-good-hash",
|
|
129
|
-
size: 128,
|
|
130
|
-
syncedAt: "2026-06-18T00:00:00.000Z",
|
|
131
|
-
direction: "down",
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
pulls: [],
|
|
135
|
-
};
|
|
136
|
-
fs.writeFileSync(getJournalPath("indigo"), '{"version":"2","files":');
|
|
137
|
-
fs.writeFileSync(
|
|
138
|
-
`${getJournalPath("indigo")}.last-good`,
|
|
139
|
-
JSON.stringify(lastGood),
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
const recovered = readJournal("indigo");
|
|
143
|
-
expect(recovered.version).toBe("2");
|
|
144
|
-
expect(recovered.lastSync).toBe(lastGood.lastSync);
|
|
145
|
-
expect(recovered.files["docs/stable.md"]).toEqual(
|
|
146
|
-
lastGood.files["docs/stable.md"],
|
|
147
|
-
);
|
|
148
|
-
expect(recovered.pulls).toEqual([]);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("R-F08: surfaces primary IO errors instead of masking them with last-good", () => {
|
|
152
|
-
const journalPath = getJournalPath("indigo");
|
|
153
|
-
fs.mkdirSync(journalPath);
|
|
154
|
-
|
|
155
|
-
expect(() => readJournal("indigo")).toThrow(/EISDIR|directory/i);
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe("writeJournal", () => {
|
|
160
|
-
it("creates the state directory if it doesn't exist", () => {
|
|
161
|
-
const nestedDir = path.join(stateDir, "nested", "deep");
|
|
162
|
-
process.env.HQ_STATE_DIR = nestedDir;
|
|
163
|
-
expect(fs.existsSync(nestedDir)).toBe(false);
|
|
164
|
-
|
|
165
|
-
writeJournal("indigo", { version: "1", lastSync: "", files: {} });
|
|
166
|
-
expect(fs.existsSync(nestedDir)).toBe(true);
|
|
167
|
-
expect(
|
|
168
|
-
fs.existsSync(path.join(nestedDir, "sync-journal.indigo.json")),
|
|
169
|
-
).toBe(true);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("keeps per-company journals independent", () => {
|
|
173
|
-
writeJournal("indigo", {
|
|
174
|
-
version: "1",
|
|
175
|
-
lastSync: "",
|
|
176
|
-
files: { "a.md": { hash: "1", size: 1, syncedAt: "", direction: "up" } },
|
|
177
|
-
});
|
|
178
|
-
writeJournal("brandstage", {
|
|
179
|
-
version: "1",
|
|
180
|
-
lastSync: "",
|
|
181
|
-
files: { "b.md": { hash: "2", size: 2, syncedAt: "", direction: "up" } },
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
const indigo = readJournal("indigo");
|
|
185
|
-
const brandstage = readJournal("brandstage");
|
|
186
|
-
expect(indigo.files).toHaveProperty("a.md");
|
|
187
|
-
expect(indigo.files).not.toHaveProperty("b.md");
|
|
188
|
-
expect(brandstage.files).toHaveProperty("b.md");
|
|
189
|
-
expect(brandstage.files).not.toHaveProperty("a.md");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("migrates once, appends authoritative updates, and refreshes the compatibility mirror", () => {
|
|
193
|
-
const initial: SyncJournal = { version: "2", lastSync: "one", files: {}, pulls: [] };
|
|
194
|
-
writeJournal("indigo", initial);
|
|
195
|
-
const locator = fs.readFileSync(getJournalPath("indigo"));
|
|
196
|
-
|
|
197
|
-
writeJournal("indigo", { version: "2", lastSync: "two", files: {}, pulls: [] });
|
|
198
|
-
|
|
199
|
-
expect(fs.readFileSync(getJournalPath("indigo"))).not.toEqual(locator);
|
|
200
|
-
expect(
|
|
201
|
-
JSON.parse(fs.readFileSync(getJournalPath("indigo"), "utf8")) as SyncJournal,
|
|
202
|
-
).toMatchObject({ version: "2", lastSync: "two" });
|
|
203
|
-
expect(readJournal("indigo").lastSync).toBe("two");
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
describe("updateEntry", () => {
|
|
208
|
-
it("stamps lastSync and the per-file syncedAt", () => {
|
|
209
|
-
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
210
|
-
const abs = path.join(stateDir, "foo.md");
|
|
211
|
-
fs.writeFileSync(abs, "x");
|
|
212
|
-
updateEntry(j, "foo.md", "hash", 10, "up", abs);
|
|
213
|
-
expect(j.files["foo.md"]?.hash).toBe("hash");
|
|
214
|
-
expect(j.files["foo.md"]?.direction).toBe("up");
|
|
215
|
-
expect(j.lastSync).not.toBe("");
|
|
216
|
-
expect(j.files["foo.md"]?.syncedAt).not.toBe("");
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// Load-bearing once delete propagation authorizes on ETag currency alone:
|
|
220
|
-
// an entry whose file was never written is indistinguishable from "the
|
|
221
|
-
// user deleted this", and the next push would issue a remote DeleteObject
|
|
222
|
-
// for what was only premature bookkeeping.
|
|
223
|
-
it("refuses to journal a path that is not on disk", () => {
|
|
224
|
-
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
225
|
-
const missing = path.join(stateDir, "never-written.md");
|
|
226
|
-
expect(() => updateEntry(j, "never-written.md", "hash", 10, "down", missing)).toThrow(
|
|
227
|
-
PrematureJournalEntryError,
|
|
228
|
-
);
|
|
229
|
-
expect(j.files).not.toHaveProperty("never-written.md");
|
|
230
|
-
expect(j.lastSync).toBe("");
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("accepts a dangling symlink — the link itself is the synced object", () => {
|
|
234
|
-
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
235
|
-
const link = path.join(stateDir, "dangling-link");
|
|
236
|
-
fs.symlinkSync(path.join(stateDir, "no-such-target"), link);
|
|
237
|
-
expect(() =>
|
|
238
|
-
updateEntry(j, "dangling-link", "hash", 0, "down", link, { kind: "symlink" }),
|
|
239
|
-
).not.toThrow();
|
|
240
|
-
expect(j.files["dangling-link"]?.kind).toBe("symlink");
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// Regression: `kind` and `createdBySub` were adjacent trailing optionals
|
|
244
|
-
// and two call sites passed kind into the createdBySub slot, leaving
|
|
245
|
-
// entry.kind unset. entry.kind gates delete-intent minting, so those
|
|
246
|
-
// entries could never be deleted. Named options make it unrepresentable.
|
|
247
|
-
it("records kind and createdBySub into their own fields", () => {
|
|
248
|
-
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
249
|
-
const abs = path.join(stateDir, "typed.md");
|
|
250
|
-
fs.writeFileSync(abs, "x");
|
|
251
|
-
updateEntry(j, "typed.md", "hash", 1, "down", abs, {
|
|
252
|
-
kind: "file",
|
|
253
|
-
createdBySub: "sub-123",
|
|
254
|
-
});
|
|
255
|
-
expect(j.files["typed.md"]?.kind).toBe("file");
|
|
256
|
-
expect(j.files["typed.md"]?.createdBySub).toBe("sub-123");
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// ── Journal v2 (US-005): pulls, tombstones, GC, migration ─────────────────
|
|
261
|
-
|
|
262
|
-
describe("migrateToV2", () => {
|
|
263
|
-
it("upgrades a v1 journal in place", () => {
|
|
264
|
-
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
265
|
-
migrateToV2(j);
|
|
266
|
-
expect(j.version).toBe("2");
|
|
267
|
-
expect(j.pulls).toEqual([]);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("is idempotent on a v2 journal", () => {
|
|
271
|
-
const j: SyncJournal = {
|
|
272
|
-
version: "2",
|
|
273
|
-
lastSync: "",
|
|
274
|
-
files: {},
|
|
275
|
-
pulls: [],
|
|
276
|
-
};
|
|
277
|
-
const before = JSON.stringify(j);
|
|
278
|
-
migrateToV2(j);
|
|
279
|
-
expect(JSON.stringify(j)).toBe(before);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it("preserves all files when migrating v1 → v2", () => {
|
|
283
|
-
const j: SyncJournal = {
|
|
284
|
-
version: "1",
|
|
285
|
-
lastSync: "2026-05-01",
|
|
286
|
-
files: {
|
|
287
|
-
"a.md": {
|
|
288
|
-
hash: "h",
|
|
289
|
-
size: 1,
|
|
290
|
-
syncedAt: "2026-05-01",
|
|
291
|
-
direction: "down",
|
|
292
|
-
},
|
|
293
|
-
},
|
|
294
|
-
};
|
|
295
|
-
migrateToV2(j);
|
|
296
|
-
expect(j.files["a.md"]).toBeDefined();
|
|
297
|
-
expect(j.files["a.md"]?.removedAt).toBeUndefined(); // not a tombstone
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it("writeJournal upgrades v1 → v2 on disk", () => {
|
|
301
|
-
writeJournal("indigo", { version: "1", lastSync: "", files: {} });
|
|
302
|
-
const raw = JSON.parse(
|
|
303
|
-
fs.readFileSync(getJournalPath("indigo"), "utf-8"),
|
|
304
|
-
) as SyncJournal;
|
|
305
|
-
expect(raw.version).toBe("2");
|
|
306
|
-
expect(raw.pulls).toEqual([]);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("normalizes backslash keys even on an already-v2 journal", () => {
|
|
310
|
-
// The landmine predates the v2 bump, so a poisoned journal can be v2.
|
|
311
|
-
// migrateToV2's early-return must not skip normalization.
|
|
312
|
-
const j: SyncJournal = {
|
|
313
|
-
version: "2",
|
|
314
|
-
lastSync: "",
|
|
315
|
-
files: {
|
|
316
|
-
"projects\\forecast\\x.csv": {
|
|
317
|
-
hash: "h",
|
|
318
|
-
size: 1,
|
|
319
|
-
syncedAt: "2026-06-10",
|
|
320
|
-
direction: "down",
|
|
321
|
-
},
|
|
322
|
-
},
|
|
323
|
-
pulls: [],
|
|
324
|
-
};
|
|
325
|
-
migrateToV2(j);
|
|
326
|
-
expect(j.files["projects\\forecast\\x.csv"]).toBeUndefined();
|
|
327
|
-
expect(j.files["projects/forecast/x.csv"]).toBeDefined();
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
describe("normalizeJournalKeys (Windows backslash-key landmine — ridge data loss)", () => {
|
|
332
|
-
// Regression for feedback_b8d09d0f: pre-5.47.2 Windows clients stamped
|
|
333
|
-
// journal keys with path.sep ("\\"). Those keys never match the
|
|
334
|
-
// forward-slash remote LIST, so the cross-machine delete planner classed
|
|
335
|
-
// live local files as remote-deleted and path.join collapsed the key back
|
|
336
|
-
// onto the real file — deleting ~36 live files in a single pull. The
|
|
337
|
-
// canonical defense is rewriting every key to POSIX form on load.
|
|
338
|
-
it("rewrites backslash keys to POSIX form and reports the count", () => {
|
|
339
|
-
const j: SyncJournal = {
|
|
340
|
-
version: "2",
|
|
341
|
-
lastSync: "",
|
|
342
|
-
files: {
|
|
343
|
-
"projects\\forecast-development\\forecasts\\sean-frank.csv": {
|
|
344
|
-
hash: "h1",
|
|
345
|
-
size: 10,
|
|
346
|
-
syncedAt: "2026-06-10",
|
|
347
|
-
direction: "down",
|
|
348
|
-
},
|
|
349
|
-
"docs\\notes.md": {
|
|
350
|
-
hash: "h2",
|
|
351
|
-
size: 5,
|
|
352
|
-
syncedAt: "2026-06-10",
|
|
353
|
-
direction: "down",
|
|
354
|
-
},
|
|
355
|
-
},
|
|
356
|
-
pulls: [],
|
|
357
|
-
};
|
|
358
|
-
const rewritten = normalizeJournalKeys(j);
|
|
359
|
-
expect(rewritten).toBe(2);
|
|
360
|
-
expect(
|
|
361
|
-
j.files["projects/forecast-development/forecasts/sean-frank.csv"],
|
|
362
|
-
).toBeDefined();
|
|
363
|
-
expect(j.files["docs/notes.md"]).toBeDefined();
|
|
364
|
-
// No malformed keys survive.
|
|
365
|
-
expect(
|
|
366
|
-
Object.keys(j.files).some((k) => k.includes("\\")),
|
|
367
|
-
).toBe(false);
|
|
368
|
-
// Entry payload is preserved verbatim under the new key.
|
|
369
|
-
expect(j.files["docs/notes.md"]?.hash).toBe("h2");
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
it("leaves a clean (all-POSIX) journal untouched and returns 0", () => {
|
|
373
|
-
const j: SyncJournal = {
|
|
374
|
-
version: "2",
|
|
375
|
-
lastSync: "",
|
|
376
|
-
files: {
|
|
377
|
-
"docs/a.md": { hash: "h", size: 1, syncedAt: "x", direction: "down" },
|
|
378
|
-
},
|
|
379
|
-
pulls: [],
|
|
380
|
-
};
|
|
381
|
-
const before = JSON.stringify(j);
|
|
382
|
-
const rewritten = normalizeJournalKeys(j);
|
|
383
|
-
expect(rewritten).toBe(0);
|
|
384
|
-
expect(JSON.stringify(j)).toBe(before);
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
it("prefers an existing POSIX twin and drops the malformed duplicate", () => {
|
|
388
|
-
// When both forms exist, the POSIX entry is authoritative (it round-
|
|
389
|
-
// tripped through an up-to-date client). The malformed dup is discarded.
|
|
390
|
-
const j: SyncJournal = {
|
|
391
|
-
version: "2",
|
|
392
|
-
lastSync: "",
|
|
393
|
-
files: {
|
|
394
|
-
"docs/a.md": {
|
|
395
|
-
hash: "posix-wins",
|
|
396
|
-
size: 1,
|
|
397
|
-
syncedAt: "x",
|
|
398
|
-
direction: "down",
|
|
399
|
-
},
|
|
400
|
-
"docs\\a.md": {
|
|
401
|
-
hash: "backslash-loses",
|
|
402
|
-
size: 2,
|
|
403
|
-
syncedAt: "x",
|
|
404
|
-
direction: "down",
|
|
405
|
-
},
|
|
406
|
-
},
|
|
407
|
-
pulls: [],
|
|
408
|
-
};
|
|
409
|
-
normalizeJournalKeys(j);
|
|
410
|
-
expect(j.files["docs\\a.md"]).toBeUndefined();
|
|
411
|
-
expect(j.files["docs/a.md"]?.hash).toBe("posix-wins");
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it("is idempotent — a second pass rewrites nothing", () => {
|
|
415
|
-
const j: SyncJournal = {
|
|
416
|
-
version: "2",
|
|
417
|
-
lastSync: "",
|
|
418
|
-
files: {
|
|
419
|
-
"a\\b\\c.txt": { hash: "h", size: 1, syncedAt: "x", direction: "down" },
|
|
420
|
-
},
|
|
421
|
-
pulls: [],
|
|
422
|
-
};
|
|
423
|
-
expect(normalizeJournalKeys(j)).toBe(1);
|
|
424
|
-
expect(normalizeJournalKeys(j)).toBe(0);
|
|
425
|
-
expect(j.files["a/b/c.txt"]).toBeDefined();
|
|
426
|
-
});
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
describe("generatePullId", () => {
|
|
430
|
-
it("produces a 26-char identifier", () => {
|
|
431
|
-
expect(generatePullId()).toHaveLength(26);
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it("is time-prefixed and lexically sortable", () => {
|
|
435
|
-
const t0 = Date.parse("2026-01-01T00:00:00.000Z");
|
|
436
|
-
const t1 = Date.parse("2026-06-01T00:00:00.000Z");
|
|
437
|
-
const id0 = generatePullId(t0);
|
|
438
|
-
const id1 = generatePullId(t1);
|
|
439
|
-
expect(id0 < id1).toBe(true);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
it("uses crockford base32 alphabet (no I, L, O, U)", () => {
|
|
443
|
-
const id = generatePullId();
|
|
444
|
-
expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]+$/);
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
describe("appendPullRecord + lastPullRecord", () => {
|
|
449
|
-
function pull(partial: Partial<PullRecord>): PullRecord {
|
|
450
|
-
return {
|
|
451
|
-
pullId: generatePullId(),
|
|
452
|
-
companyUid: "cmp_indigo",
|
|
453
|
-
startedAt: "2026-05-20T00:00:00.000Z",
|
|
454
|
-
completedAt: "2026-05-20T00:00:05.000Z",
|
|
455
|
-
syncMode: "all",
|
|
456
|
-
prefixSet: [],
|
|
457
|
-
scopeChangeDetected: false,
|
|
458
|
-
orphansRemoved: 0,
|
|
459
|
-
orphansBlocked: 0,
|
|
460
|
-
...partial,
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
it("appends to pulls[] (auto-migrates v1)", () => {
|
|
465
|
-
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
466
|
-
appendPullRecord(j, pull({}));
|
|
467
|
-
expect(j.version).toBe("2");
|
|
468
|
-
expect(j.pulls).toHaveLength(1);
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it("lastPullRecord returns undefined when no records exist", () => {
|
|
472
|
-
const j: SyncJournal = {
|
|
473
|
-
version: "2",
|
|
474
|
-
lastSync: "",
|
|
475
|
-
files: {},
|
|
476
|
-
pulls: [],
|
|
477
|
-
};
|
|
478
|
-
expect(lastPullRecord(j, "cmp_indigo")).toBeUndefined();
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it("lastPullRecord returns the most-recent by completedAt", () => {
|
|
482
|
-
const j: SyncJournal = {
|
|
483
|
-
version: "2",
|
|
484
|
-
lastSync: "",
|
|
485
|
-
files: {},
|
|
486
|
-
pulls: [
|
|
487
|
-
pull({ completedAt: "2026-05-19T00:00:00.000Z", syncMode: "all" }),
|
|
488
|
-
pull({ completedAt: "2026-05-20T00:00:00.000Z", syncMode: "shared" }),
|
|
489
|
-
pull({ completedAt: "2026-05-18T00:00:00.000Z", syncMode: "all" }),
|
|
490
|
-
],
|
|
491
|
-
};
|
|
492
|
-
expect(lastPullRecord(j, "cmp_indigo")?.syncMode).toBe("shared");
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it("lastPullRecord filters by companyUid", () => {
|
|
496
|
-
const j: SyncJournal = {
|
|
497
|
-
version: "2",
|
|
498
|
-
lastSync: "",
|
|
499
|
-
files: {},
|
|
500
|
-
pulls: [
|
|
501
|
-
pull({ companyUid: "cmp_a", completedAt: "2026-05-20T00:00:00.000Z" }),
|
|
502
|
-
pull({ companyUid: "cmp_b", completedAt: "2026-05-21T00:00:00.000Z" }),
|
|
503
|
-
],
|
|
504
|
-
};
|
|
505
|
-
expect(lastPullRecord(j, "cmp_a")?.completedAt).toBe(
|
|
506
|
-
"2026-05-20T00:00:00.000Z",
|
|
507
|
-
);
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
it("bounds pulls per company and preserves newest lastPullRecord", () => {
|
|
511
|
-
const j: SyncJournal = {
|
|
512
|
-
version: "2",
|
|
513
|
-
lastSync: "",
|
|
514
|
-
files: {},
|
|
515
|
-
pulls: [],
|
|
516
|
-
};
|
|
517
|
-
const completedAt = (n: number) =>
|
|
518
|
-
`2026-05-20T00:00:${String(n).padStart(2, "0")}.000Z`;
|
|
519
|
-
|
|
520
|
-
for (let i = 0; i < MAX_PULLS_PER_COMPANY + 5; i++) {
|
|
521
|
-
appendPullRecord(
|
|
522
|
-
j,
|
|
523
|
-
pull({
|
|
524
|
-
pullId: `pull-a-${String(i).padStart(2, "0")}`,
|
|
525
|
-
companyUid: "cmp_a",
|
|
526
|
-
completedAt: completedAt(i),
|
|
527
|
-
}),
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
for (let i = 0; i < 3; i++) {
|
|
531
|
-
appendPullRecord(
|
|
532
|
-
j,
|
|
533
|
-
pull({
|
|
534
|
-
pullId: `pull-b-${i}`,
|
|
535
|
-
companyUid: "cmp_b",
|
|
536
|
-
completedAt: completedAt(i),
|
|
537
|
-
}),
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
const pulls = j.pulls ?? [];
|
|
542
|
-
const companyA = pulls.filter((p) => p.companyUid === "cmp_a");
|
|
543
|
-
const companyB = pulls.filter((p) => p.companyUid === "cmp_b");
|
|
544
|
-
expect(companyA).toHaveLength(MAX_PULLS_PER_COMPANY);
|
|
545
|
-
expect(companyB).toHaveLength(3);
|
|
546
|
-
expect(companyA[0].pullId).toBe("pull-a-05");
|
|
547
|
-
expect(companyA.at(-1)?.pullId).toBe("pull-a-54");
|
|
548
|
-
expect(companyA.some((p) => p.pullId === "pull-a-00")).toBe(false);
|
|
549
|
-
expect(lastPullRecord(j, "cmp_a")?.pullId).toBe("pull-a-54");
|
|
550
|
-
expect(lastPullRecord(j, "cmp_b")?.pullId).toBe("pull-b-2");
|
|
551
|
-
});
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
describe("tombstoneEntry + isTombstone", () => {
|
|
555
|
-
it("marks an entry with removedAt + removedReason", () => {
|
|
556
|
-
const j: SyncJournal = {
|
|
557
|
-
version: "2",
|
|
558
|
-
lastSync: "",
|
|
559
|
-
files: {
|
|
560
|
-
"a.md": {
|
|
561
|
-
hash: "h",
|
|
562
|
-
size: 1,
|
|
563
|
-
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
564
|
-
direction: "down",
|
|
565
|
-
},
|
|
566
|
-
},
|
|
567
|
-
pulls: [],
|
|
568
|
-
};
|
|
569
|
-
tombstoneEntry(j, "a.md", "scope_shrink", "2026-05-20T00:00:00.000Z");
|
|
570
|
-
expect(j.files["a.md"]?.removedAt).toBe("2026-05-20T00:00:00.000Z");
|
|
571
|
-
expect(j.files["a.md"]?.removedReason).toBe("scope_shrink");
|
|
572
|
-
expect(isTombstone(j.files["a.md"])).toBe(true);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
it("is a no-op for missing entries", () => {
|
|
576
|
-
const j: SyncJournal = {
|
|
577
|
-
version: "2",
|
|
578
|
-
lastSync: "",
|
|
579
|
-
files: {},
|
|
580
|
-
pulls: [],
|
|
581
|
-
};
|
|
582
|
-
expect(() =>
|
|
583
|
-
tombstoneEntry(j, "missing.md", "scope_shrink"),
|
|
584
|
-
).not.toThrow();
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
it("preserves the original hash/size/syncedAt (forensic trail)", () => {
|
|
588
|
-
const j: SyncJournal = {
|
|
589
|
-
version: "2",
|
|
590
|
-
lastSync: "",
|
|
591
|
-
files: {
|
|
592
|
-
"a.md": {
|
|
593
|
-
hash: "old-hash",
|
|
594
|
-
size: 42,
|
|
595
|
-
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
596
|
-
direction: "down",
|
|
597
|
-
},
|
|
598
|
-
},
|
|
599
|
-
pulls: [],
|
|
600
|
-
};
|
|
601
|
-
tombstoneEntry(j, "a.md", "scope_shrink");
|
|
602
|
-
expect(j.files["a.md"]?.hash).toBe("old-hash");
|
|
603
|
-
expect(j.files["a.md"]?.size).toBe(42);
|
|
604
|
-
});
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
describe("gcTombstones", () => {
|
|
608
|
-
it("removes tombstones older than TOMBSTONE_TTL_MS", () => {
|
|
609
|
-
const now = Date.parse("2026-06-01T00:00:00.000Z");
|
|
610
|
-
const old = new Date(now - TOMBSTONE_TTL_MS - 1000).toISOString();
|
|
611
|
-
const fresh = new Date(now - 1000).toISOString();
|
|
612
|
-
const j: SyncJournal = {
|
|
613
|
-
version: "2",
|
|
614
|
-
lastSync: "",
|
|
615
|
-
files: {
|
|
616
|
-
"old-tombstone.md": {
|
|
617
|
-
hash: "h",
|
|
618
|
-
size: 1,
|
|
619
|
-
syncedAt: "2026-04-01T00:00:00.000Z",
|
|
620
|
-
direction: "down",
|
|
621
|
-
removedAt: old,
|
|
622
|
-
removedReason: "scope_shrink",
|
|
623
|
-
},
|
|
624
|
-
"fresh-tombstone.md": {
|
|
625
|
-
hash: "h",
|
|
626
|
-
size: 1,
|
|
627
|
-
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
628
|
-
direction: "down",
|
|
629
|
-
removedAt: fresh,
|
|
630
|
-
removedReason: "scope_shrink",
|
|
631
|
-
},
|
|
632
|
-
"live.md": {
|
|
633
|
-
hash: "h",
|
|
634
|
-
size: 1,
|
|
635
|
-
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
636
|
-
direction: "down",
|
|
637
|
-
},
|
|
638
|
-
},
|
|
639
|
-
pulls: [],
|
|
640
|
-
};
|
|
641
|
-
const removed = gcTombstones(j, now);
|
|
642
|
-
expect(removed).toBe(1);
|
|
643
|
-
expect(j.files["old-tombstone.md"]).toBeUndefined();
|
|
644
|
-
expect(j.files["fresh-tombstone.md"]).toBeDefined();
|
|
645
|
-
expect(j.files["live.md"]).toBeDefined();
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
it("never removes non-tombstone entries", () => {
|
|
649
|
-
const j: SyncJournal = {
|
|
650
|
-
version: "2",
|
|
651
|
-
lastSync: "",
|
|
652
|
-
files: {
|
|
653
|
-
"live.md": {
|
|
654
|
-
hash: "h",
|
|
655
|
-
size: 1,
|
|
656
|
-
syncedAt: "1970-01-01T00:00:00.000Z",
|
|
657
|
-
direction: "down",
|
|
658
|
-
},
|
|
659
|
-
},
|
|
660
|
-
pulls: [],
|
|
661
|
-
};
|
|
662
|
-
expect(gcTombstones(j, Date.now())).toBe(0);
|
|
663
|
-
expect(j.files["live.md"]).toBeDefined();
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
it("ignores tombstones with un-parseable removedAt", () => {
|
|
667
|
-
const j: SyncJournal = {
|
|
668
|
-
version: "2",
|
|
669
|
-
lastSync: "",
|
|
670
|
-
files: {
|
|
671
|
-
"bogus.md": {
|
|
672
|
-
hash: "h",
|
|
673
|
-
size: 1,
|
|
674
|
-
syncedAt: "",
|
|
675
|
-
direction: "down",
|
|
676
|
-
removedAt: "not-a-date",
|
|
677
|
-
removedReason: "scope_shrink",
|
|
678
|
-
},
|
|
679
|
-
},
|
|
680
|
-
pulls: [],
|
|
681
|
-
};
|
|
682
|
-
expect(gcTombstones(j, Date.now())).toBe(0);
|
|
683
|
-
expect(j.files["bogus.md"]).toBeDefined();
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
// ── Personal-vault journal-slug collision fix ──────────────────────────────
|
|
688
|
-
//
|
|
689
|
-
// Root cause: the --companies fanout's personal-vault slot journaled under
|
|
690
|
-
// the literal slug "personal", colliding with the companies/personal
|
|
691
|
-
// company (entity slug "personal"). The two targets have different sync
|
|
692
|
-
// roots, so the company's whole-tree delete-plan walked the shared
|
|
693
|
-
// sync-journal.personal.json, resolved the vault's hq-root keys against
|
|
694
|
-
// hqRoot/companies/personal (where they don't exist), tombstoned them as
|
|
695
|
-
// "remote already 404", and dropped them — only for the vault slot to
|
|
696
|
-
// re-upload them next cycle (~190 .claude/skills/* files of churn/sync).
|
|
697
|
-
describe("PERSONAL_VAULT_JOURNAL_SLUG", () => {
|
|
698
|
-
it("is decoupled from the 'personal' company slug", () => {
|
|
699
|
-
expect(PERSONAL_VAULT_JOURNAL_SLUG).not.toBe("personal");
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
it("survives sanitizeSlug unchanged (no path/dot/empty mangling)", () => {
|
|
703
|
-
// getJournalPath runs the slug through sanitizeSlug; the sentinel must
|
|
704
|
-
// pass through verbatim (it is all [a-zA-Z0-9_-] and not all [_-]).
|
|
705
|
-
expect(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG)).toBe(
|
|
706
|
-
path.join(
|
|
707
|
-
stateDir,
|
|
708
|
-
`sync-journal.${PERSONAL_VAULT_JOURNAL_SLUG}.json`,
|
|
709
|
-
),
|
|
710
|
-
);
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
it("maps to a different journal file than 'personal'", () => {
|
|
714
|
-
expect(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG)).not.toBe(
|
|
715
|
-
getJournalPath("personal"),
|
|
716
|
-
);
|
|
717
|
-
});
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
describe("migratePersonalVaultJournal", () => {
|
|
721
|
-
const legacyJournal = (): SyncJournal => ({
|
|
722
|
-
version: "2",
|
|
723
|
-
lastSync: "2026-05-22T00:00:00.000Z",
|
|
724
|
-
files: {
|
|
725
|
-
".claude/skills/deep-plan/SKILL.md": {
|
|
726
|
-
hash: "abc123",
|
|
727
|
-
size: 42,
|
|
728
|
-
syncedAt: "2026-05-22T00:00:00.000Z",
|
|
729
|
-
direction: "up",
|
|
730
|
-
},
|
|
731
|
-
},
|
|
732
|
-
pulls: [],
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
it("seeds the reserved slug from the legacy 'personal' journal when absent", () => {
|
|
736
|
-
writeJournal("personal", legacyJournal());
|
|
737
|
-
expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
|
|
738
|
-
|
|
739
|
-
migratePersonalVaultJournal();
|
|
740
|
-
|
|
741
|
-
expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(true);
|
|
742
|
-
const seeded = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
743
|
-
// The hq-root-relative vault entry is preserved so it is NOT re-uploaded.
|
|
744
|
-
expect(seeded.files[".claude/skills/deep-plan/SKILL.md"]).toEqual(
|
|
745
|
-
legacyJournal().files[".claude/skills/deep-plan/SKILL.md"],
|
|
746
|
-
);
|
|
747
|
-
// Legacy journal is left intact (still used by the companies/personal company).
|
|
748
|
-
expect(fs.existsSync(getJournalPath("personal"))).toBe(true);
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
it("is idempotent — does not clobber an existing reserved-slug journal", () => {
|
|
752
|
-
// Pre-existing reserved-slug journal with distinct content.
|
|
753
|
-
const existing: SyncJournal = {
|
|
754
|
-
version: "2",
|
|
755
|
-
lastSync: "2026-05-22T12:00:00.000Z",
|
|
756
|
-
files: {
|
|
757
|
-
"core/keep-me.md": {
|
|
758
|
-
hash: "keep",
|
|
759
|
-
size: 7,
|
|
760
|
-
syncedAt: "2026-05-22T12:00:00.000Z",
|
|
761
|
-
direction: "up",
|
|
762
|
-
},
|
|
763
|
-
},
|
|
764
|
-
pulls: [],
|
|
765
|
-
};
|
|
766
|
-
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, existing);
|
|
767
|
-
writeJournal("personal", legacyJournal());
|
|
768
|
-
|
|
769
|
-
migratePersonalVaultJournal();
|
|
770
|
-
|
|
771
|
-
const after = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
772
|
-
expect(after.files["core/keep-me.md"]).toBeDefined();
|
|
773
|
-
// The legacy entry was NOT copied over the existing reserved journal.
|
|
774
|
-
expect(after.files[".claude/skills/deep-plan/SKILL.md"]).toBeUndefined();
|
|
775
|
-
});
|
|
776
|
-
|
|
777
|
-
it("no-ops when the legacy 'personal' journal is absent", () => {
|
|
778
|
-
expect(fs.existsSync(getJournalPath("personal"))).toBe(false);
|
|
779
|
-
|
|
780
|
-
migratePersonalVaultJournal();
|
|
781
|
-
|
|
782
|
-
expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
|
|
783
|
-
});
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
// Regression: `hq sync status` reported "No sync journal yet" right after a
|
|
787
|
-
// successful sync because it read one path keyed off the HQ-ROOT, while the
|
|
788
|
-
// engine writes slug-sharded journals (personal vault + per-company).
|
|
789
|
-
// `listJournals` is the all-shard enumeration the status surface now uses.
|
|
790
|
-
it("binds a local delete intent to the captured remote revision", () => {
|
|
791
|
-
const journal: SyncJournal = {
|
|
792
|
-
version: "2",
|
|
793
|
-
lastSync: "",
|
|
794
|
-
pulls: [],
|
|
795
|
-
files: {
|
|
796
|
-
"docs/a.md": {
|
|
797
|
-
hash: "local-hash",
|
|
798
|
-
size: 1,
|
|
799
|
-
syncedAt: new Date().toISOString(),
|
|
800
|
-
direction: "down",
|
|
801
|
-
kind: "file",
|
|
802
|
-
remoteEtag: "new-etag",
|
|
803
|
-
},
|
|
804
|
-
},
|
|
805
|
-
};
|
|
806
|
-
|
|
807
|
-
expect(
|
|
808
|
-
markLocalDeleteIntent(
|
|
809
|
-
journal,
|
|
810
|
-
"docs/a.md",
|
|
811
|
-
"local-hash",
|
|
812
|
-
"file",
|
|
813
|
-
"old-etag",
|
|
814
|
-
),
|
|
815
|
-
).toBe(false);
|
|
816
|
-
expect(journal.files["docs/a.md"]?.localDeleteIntent).toBeUndefined();
|
|
817
|
-
|
|
818
|
-
expect(
|
|
819
|
-
markLocalDeleteIntent(
|
|
820
|
-
journal,
|
|
821
|
-
"docs/a.md",
|
|
822
|
-
"local-hash",
|
|
823
|
-
"file",
|
|
824
|
-
"new-etag",
|
|
825
|
-
),
|
|
826
|
-
).toBe(true);
|
|
827
|
-
expect(journal.files["docs/a.md"]?.localDeleteIntent).toMatchObject({
|
|
828
|
-
remoteEtag: "new-etag",
|
|
829
|
-
localHash: "local-hash",
|
|
830
|
-
localKind: "file",
|
|
831
|
-
});
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
describe("listJournals", () => {
|
|
835
|
-
function seed(slug: string, files: SyncJournal["files"]): void {
|
|
836
|
-
writeJournal(slug, {
|
|
837
|
-
version: "2",
|
|
838
|
-
lastSync: "",
|
|
839
|
-
files,
|
|
840
|
-
pulls: [],
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
it("returns [] when the state dir has no journals (truly-empty case)", () => {
|
|
845
|
-
expect(listJournals()).toEqual([]);
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
it("returns [] when the state dir does not exist at all", () => {
|
|
849
|
-
process.env.HQ_STATE_DIR = path.join(stateDir, "does-not-exist");
|
|
850
|
-
expect(listJournals()).toEqual([]);
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
it("enumerates the personal-vault shard written by a `--personal` sync", () => {
|
|
854
|
-
seed(PERSONAL_VAULT_JOURNAL_SLUG, {
|
|
855
|
-
".claude/skills/x/SKILL.md": {
|
|
856
|
-
hash: "a",
|
|
857
|
-
size: 10,
|
|
858
|
-
syncedAt: "2026-06-08T10:00:00.000Z",
|
|
859
|
-
direction: "up",
|
|
860
|
-
},
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
const all = listJournals();
|
|
864
|
-
expect(all).toHaveLength(1);
|
|
865
|
-
expect(all[0]!.slug).toBe(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
866
|
-
expect(all[0]!.path).toBe(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG));
|
|
867
|
-
expect(Object.keys(all[0]!.journal.files)).toContain(
|
|
868
|
-
".claude/skills/x/SKILL.md",
|
|
869
|
-
);
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
it("enumerates a per-company shard written by a company sync", () => {
|
|
873
|
-
seed("marshalops", {
|
|
874
|
-
"knowledge/a.md": {
|
|
875
|
-
hash: "b",
|
|
876
|
-
size: 20,
|
|
877
|
-
syncedAt: "2026-06-08T11:00:00.000Z",
|
|
878
|
-
direction: "down",
|
|
879
|
-
},
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
const all = listJournals();
|
|
883
|
-
expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
it("enumerates ALL shards together, sorted by slug", () => {
|
|
887
|
-
seed(PERSONAL_VAULT_JOURNAL_SLUG, {
|
|
888
|
-
".claude/x": {
|
|
889
|
-
hash: "a",
|
|
890
|
-
size: 1,
|
|
891
|
-
syncedAt: "2026-06-08T10:00:00.000Z",
|
|
892
|
-
direction: "up",
|
|
893
|
-
},
|
|
894
|
-
});
|
|
895
|
-
seed("marshalops", {
|
|
896
|
-
"k/a": {
|
|
897
|
-
hash: "b",
|
|
898
|
-
size: 2,
|
|
899
|
-
syncedAt: "2026-06-08T11:00:00.000Z",
|
|
900
|
-
direction: "down",
|
|
901
|
-
},
|
|
902
|
-
});
|
|
903
|
-
seed("glazeymedia", {
|
|
904
|
-
"k/b": {
|
|
905
|
-
hash: "c",
|
|
906
|
-
size: 3,
|
|
907
|
-
syncedAt: "2026-06-08T12:00:00.000Z",
|
|
908
|
-
direction: "down",
|
|
909
|
-
},
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
const slugs = listJournals().map((j) => j.slug);
|
|
913
|
-
// Sorted: "__hq_personal_vault__" < "glazeymedia" < "marshalops".
|
|
914
|
-
expect(slugs).toEqual([
|
|
915
|
-
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
916
|
-
"glazeymedia",
|
|
917
|
-
"marshalops",
|
|
918
|
-
]);
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
it("skips a corrupt shard but still returns the healthy ones", () => {
|
|
922
|
-
seed("marshalops", {
|
|
923
|
-
"k/a": {
|
|
924
|
-
hash: "b",
|
|
925
|
-
size: 2,
|
|
926
|
-
syncedAt: "2026-06-08T11:00:00.000Z",
|
|
927
|
-
direction: "down",
|
|
928
|
-
},
|
|
929
|
-
});
|
|
930
|
-
fs.writeFileSync(
|
|
931
|
-
path.join(stateDir, "sync-journal.broken.json"),
|
|
932
|
-
"{ not valid json",
|
|
933
|
-
);
|
|
934
|
-
|
|
935
|
-
const all = listJournals();
|
|
936
|
-
expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
it("R-F08: recovers a torn shard primary from .last-good in listJournals", () => {
|
|
940
|
-
seed("marshalops", {
|
|
941
|
-
"k/recovered.md": {
|
|
942
|
-
hash: "last-good-hash",
|
|
943
|
-
size: 2,
|
|
944
|
-
syncedAt: "2026-06-19T11:00:00.000Z",
|
|
945
|
-
direction: "down",
|
|
946
|
-
},
|
|
947
|
-
});
|
|
948
|
-
fs.writeFileSync(getJournalPath("marshalops"), "");
|
|
949
|
-
|
|
950
|
-
const all = listJournals();
|
|
951
|
-
|
|
952
|
-
expect(all).toHaveLength(1);
|
|
953
|
-
expect(all[0]!.slug).toBe("marshalops");
|
|
954
|
-
expect(all[0]!.journal.files["k/recovered.md"]?.hash).toBe(
|
|
955
|
-
"last-good-hash",
|
|
956
|
-
);
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
it("ignores unrelated files in the state dir", () => {
|
|
960
|
-
seed("marshalops", {});
|
|
961
|
-
fs.writeFileSync(path.join(stateDir, "config.json"), "{}");
|
|
962
|
-
fs.writeFileSync(path.join(stateDir, "sync-journal.json"), "{}"); // no slug
|
|
963
|
-
fs.writeFileSync(path.join(stateDir, "notes.txt"), "hi");
|
|
964
|
-
|
|
965
|
-
expect(listJournals().map((j) => j.slug)).toEqual(["marshalops"]);
|
|
966
|
-
});
|
|
967
|
-
});
|
|
968
|
-
});
|